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 001/232] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E6=B3=A5?= =?UTF-8?q?=E7=82=B9=E4=B8=89=E6=B1=A0=E4=B8=8E=E4=BC=9A=E5=91=98=E8=AE=A1?= =?UTF-8?q?=E8=B4=B9=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`**:确认删除。 From b827c93d39fccbf2b1df090ac7bb1d02bf10737a 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:20 +0800 Subject: [PATCH 002/232] =?UTF-8?q?=E9=A2=86=E5=9F=9F=E5=B1=82=EF=BC=9A?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BC=9A=E5=91=98=E6=A1=A3=E4=BD=8D=E3=80=81?= =?UTF-8?q?=E5=91=A8=E6=9C=9F=E3=80=81=E6=A8=A1=E5=9E=8B=E6=9D=83=E9=99=90?= =?UTF-8?q?=E4=B8=8E=E5=8F=98=E6=9B=B4=E7=B1=BB=E5=9E=8B=E6=9E=9A=E4=B8=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 RuntimeProfileMembershipPlan { Normal, Starter, Plus, Pro, Max } 作为档位权威身份,取代旧 RuntimeProfileMembershipTier - 新增 RuntimeProfileMembershipCycleKind:月付 1 期、年付 12 期(每期仍为一个自然月) - 新增 RuntimeProfileMembershipModelAccess { Basic, Full } 与 RuntimeProfileMembershipChangeKind { Purchase, Upgrade } - 四个枚举各带 as_str 与 parse,未知对外 token 返回 None,由调用方显式拒绝 - 新增集成测试 tests/membership_plan_enum.rs:token 往返、Normal 不可购买、期数映射、未知 token 拒绝 Co-authored-by: Junie --- server-rs/crates/module-runtime/src/domain.rs | 125 ++++++++++++++++++ .../tests/membership_plan_enum.rs | 81 ++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 server-rs/crates/module-runtime/tests/membership_plan_enum.rs diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 44148900c..9d60e5cef 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1255,6 +1255,131 @@ impl RuntimeProfileMembershipTier { } } +/// 会员档位身份。取代旧的 `RuntimeProfileMembershipTier`,作为唯一权威档位标识。 +/// +/// 变体顺序即排序基准(`Normal < Starter < Plus < Pro < Max`),升级比较不再依赖旧枚举别名。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileMembershipPlan { + Normal, + Starter, + Plus, + Pro, + Max, +} + +impl RuntimeProfileMembershipPlan { + pub fn as_str(&self) -> &'static str { + match self { + Self::Normal => "normal", + Self::Starter => "starter", + Self::Plus => "plus", + Self::Pro => "pro", + Self::Max => "max", + } + } + + /// 严格解析对外 wire token;未知 token 返回 `None`,调用方必须显式拒绝,不接受自由字符串。 + pub fn parse(value: &str) -> Option { + match value { + "normal" => Some(Self::Normal), + "starter" => Some(Self::Starter), + "plus" => Some(Self::Plus), + "pro" => Some(Self::Pro), + "max" => Some(Self::Max), + _ => None, + } + } + + /// `Normal` 不是可售档位,其余档位可在充值中心购买。 + pub fn is_purchasable(&self) -> bool { + !matches!(self, Self::Normal) + } +} + +/// 会员购买周期类型。它只决定本次购买的总期数与到期日,单期窗口一律按自然月推进。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileMembershipCycleKind { + Monthly, + Yearly, +} + +impl RuntimeProfileMembershipCycleKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::Monthly => "monthly", + Self::Yearly => "yearly", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "monthly" => Some(Self::Monthly), + "yearly" => Some(Self::Yearly), + _ => None, + } + } + + /// 本次购买的总期数:月付 1 期、年付 12 期(每期仍是一个自然月)。 + pub fn cycle_count(&self) -> u32 { + match self { + Self::Monthly => 1, + Self::Yearly => 12, + } + } +} + +/// 档位允许选择的模型范围。本期只落字段、只用于展示,服务端不做拦截。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileMembershipModelAccess { + Basic, + Full, +} + +impl RuntimeProfileMembershipModelAccess { + pub fn as_str(&self) -> &'static str { + match self { + Self::Basic => "basic", + Self::Full => "full", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "basic" => Some(Self::Basic), + "full" => Some(Self::Full), + _ => None, + } + } +} + +/// 会员订单产生的权益变更类型,用于订单快照区分「新购」与「补差升级」。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileMembershipChangeKind { + Purchase, + Upgrade, +} + +impl RuntimeProfileMembershipChangeKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::Purchase => "purchase", + Self::Upgrade => "upgrade", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "purchase" => Some(Self::Purchase), + "upgrade" => Some(Self::Upgrade), + _ => None, + } + } +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimeProfileRechargeOrderStatus { diff --git a/server-rs/crates/module-runtime/tests/membership_plan_enum.rs b/server-rs/crates/module-runtime/tests/membership_plan_enum.rs new file mode 100644 index 000000000..fb3cdfda1 --- /dev/null +++ b/server-rs/crates/module-runtime/tests/membership_plan_enum.rs @@ -0,0 +1,81 @@ +use module_runtime::{ + RuntimeProfileMembershipChangeKind, RuntimeProfileMembershipCycleKind, + RuntimeProfileMembershipModelAccess, RuntimeProfileMembershipPlan, +}; + +#[test] +fn membership_plan_tokens_round_trip_strictly() { + // 中文注释:对外 wire token 必须与枚举一一对应,未知 token 一律解析失败。 + let cases = [ + (RuntimeProfileMembershipPlan::Normal, "normal"), + (RuntimeProfileMembershipPlan::Starter, "starter"), + (RuntimeProfileMembershipPlan::Plus, "plus"), + (RuntimeProfileMembershipPlan::Pro, "pro"), + (RuntimeProfileMembershipPlan::Max, "max"), + ]; + for (plan, token) in cases { + assert_eq!(plan.as_str(), token); + assert_eq!(RuntimeProfileMembershipPlan::parse(token), Some(plan)); + } + + assert_eq!(RuntimeProfileMembershipPlan::parse("basic"), None); + assert_eq!(RuntimeProfileMembershipPlan::parse("ultimate"), None); + assert_eq!(RuntimeProfileMembershipPlan::parse("PLUS"), None); + assert_eq!(RuntimeProfileMembershipPlan::parse(""), None); +} + +#[test] +fn membership_plan_normal_is_not_purchasable() { + // 中文注释:Normal 是非会员占位档位,不允许被购买。 + assert!(!RuntimeProfileMembershipPlan::Normal.is_purchasable()); + for plan in [ + RuntimeProfileMembershipPlan::Starter, + RuntimeProfileMembershipPlan::Plus, + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipPlan::Max, + ] { + assert!(plan.is_purchasable()); + } +} + +#[test] +fn membership_cycle_kind_maps_to_period_count() { + // 中文注释:年付 12 期、月付 1 期,每期仍是一个自然月。 + assert_eq!(RuntimeProfileMembershipCycleKind::Monthly.cycle_count(), 1); + assert_eq!(RuntimeProfileMembershipCycleKind::Yearly.cycle_count(), 12); + assert_eq!( + RuntimeProfileMembershipCycleKind::Monthly.as_str(), + "monthly" + ); + assert_eq!(RuntimeProfileMembershipCycleKind::Yearly.as_str(), "yearly"); + assert_eq!( + RuntimeProfileMembershipCycleKind::parse("yearly"), + Some(RuntimeProfileMembershipCycleKind::Yearly) + ); + assert_eq!(RuntimeProfileMembershipCycleKind::parse("weekly"), None); +} + +#[test] +fn membership_model_access_and_change_kind_tokens_round_trip() { + assert_eq!(RuntimeProfileMembershipModelAccess::Basic.as_str(), "basic"); + assert_eq!(RuntimeProfileMembershipModelAccess::Full.as_str(), "full"); + assert_eq!( + RuntimeProfileMembershipModelAccess::parse("full"), + Some(RuntimeProfileMembershipModelAccess::Full) + ); + assert_eq!(RuntimeProfileMembershipModelAccess::parse("premium"), None); + + assert_eq!( + RuntimeProfileMembershipChangeKind::Purchase.as_str(), + "purchase" + ); + assert_eq!( + RuntimeProfileMembershipChangeKind::Upgrade.as_str(), + "upgrade" + ); + assert_eq!( + RuntimeProfileMembershipChangeKind::parse("upgrade"), + Some(RuntimeProfileMembershipChangeKind::Upgrade) + ); + assert_eq!(RuntimeProfileMembershipChangeKind::parse("downgrade"), None); +} From 741b179ca50116f57eb2219fea94d7630b6afe66 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:08:04 +0800 Subject: [PATCH 003/232] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E6=8A=BD?= =?UTF-8?q?=E5=87=BA=E5=85=AC=E5=8E=86=E6=97=A5=E6=9C=9F=E5=9F=BA=E5=85=83?= =?UTF-8?q?=E4=B8=BA=E7=8B=AC=E7=AB=8B=E6=A8=A1=E5=9D=97=20civil=5Fcalenda?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 server-rs/crates/module-runtime/src/civil_calendar.rs:days_in_month / is_leap_year / days_from_civil / civil_from_days / weekday_from_date_key - application.rs 删除上述重复实现,改为引用新模块,行为不变 - 新模块自带单元测试:闰年规则、日序号往返、已知日期星期 - 后续会员自然月账期、分析维表等纯日期逻辑统一复用该模块 Co-authored-by: Junie --- .../crates/module-runtime/src/application.rs | 52 +--------- .../module-runtime/src/civil_calendar.rs | 94 +++++++++++++++++++ server-rs/crates/module-runtime/src/lib.rs | 2 + 3 files changed, 99 insertions(+), 49 deletions(-) create mode 100644 server-rs/crates/module-runtime/src/civil_calendar.rs diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 8e7bd07c8..19de95527 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -8,6 +8,9 @@ use serde_json::Value; use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339}; use std::collections::{BTreeMap, BTreeSet}; +use crate::civil_calendar::{ + civil_from_days, days_from_civil, days_in_month, is_leap_year, weekday_from_date_key, +}; use crate::domain::*; use crate::errors::RuntimeProfileFieldError; use crate::format_utc_micros; @@ -1375,25 +1378,6 @@ fn validate_calendar_date(year: i32, month: u8, day: u8) -> Result<(), RuntimePr Ok(()) } -fn days_in_month(year: i32, month: u8) -> u8 { - match month { - 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, - 4 | 6 | 9 | 11 => 30, - 2 if is_leap_year(year) => 29, - 2 => 28, - _ => 0, - } -} - -fn is_leap_year(year: i32) -> bool { - (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 -} - -fn weekday_from_date_key(date_key: i64) -> u8 { - // 中文注释:1970-01-01 是周四;这里返回 ISO weekday,周一=1,周日=7。 - (date_key + 3).rem_euclid(7) as u8 + 1 -} - fn iso_week_key(year: i32, month: u8, day: u8, weekday: u8) -> i32 { let ordinal = ordinal_day(year, month, day); let week = (i32::from(ordinal) - i32::from(weekday) + 10).div_euclid(7); @@ -1430,36 +1414,6 @@ fn iso_weeks_in_year(year: i32) -> i32 { } } -fn days_from_civil(year: i32, month: u8, day: u8) -> i64 { - // 中文注释:Howard Hinnant civil calendar 算法,返回 1970-01-01 起的日序号。 - let adjusted_year = year - if month <= 2 { 1 } else { 0 }; - let era = adjusted_year.div_euclid(400); - let year_of_era = adjusted_year - era * 400; - let month = i32::from(month); - let day = i32::from(day); - let month_prime = month + if month > 2 { -3 } else { 9 }; - let day_of_year = (153 * month_prime + 2) / 5 + day - 1; - let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; - i64::from(era * 146_097 + day_of_era - 719_468) -} - -fn civil_from_days(date_key: i64) -> (i32, u8, u8) { - // 中文注释:days_from_civil 的反向算法,避免依赖运行环境时区。 - let z = date_key + 719_468; - let era = z.div_euclid(146_097); - let day_of_era = z - era * 146_097; - let year_of_era = (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - - day_of_era / 146_096) - .div_euclid(365); - let mut year = year_of_era + era * 400; - let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); - let month_prime = (5 * day_of_year + 2).div_euclid(153); - let day = day_of_year - (153 * month_prime + 2).div_euclid(5) + 1; - let month = month_prime + if month_prime < 10 { 3 } else { -9 }; - year += if month <= 2 { 1 } else { 0 }; - (year as i32, month as u8, day as u8) -} - pub fn build_default_runtime_profile_task_config( updated_at_micros: i64, updated_by: String, diff --git a/server-rs/crates/module-runtime/src/civil_calendar.rs b/server-rs/crates/module-runtime/src/civil_calendar.rs new file mode 100644 index 000000000..60c0a4ec8 --- /dev/null +++ b/server-rs/crates/module-runtime/src/civil_calendar.rs @@ -0,0 +1,94 @@ +//! 公历日期基元(不依赖运行环境时区)。 +//! +//! 只做「公历年月日 ↔ 1970-01-01 起日序号」的纯换算,采用 Howard Hinnant 的 civil calendar +//! 算法。北京时间(UTC+8)与业务日边界由调用方叠加固定偏移,因此本模块永远不读系统时区。 + +/// 公历给定年月的天数,含闰年规则。 +pub fn days_in_month(year: i32, month: u8) -> u8 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if is_leap_year(year) => 29, + 2 => 28, + _ => 0, + } +} + +/// 公历闰年判断。 +pub fn is_leap_year(year: i32) -> bool { + (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 +} + +/// 公历年月日 → 1970-01-01 起的日序号。 +pub fn days_from_civil(year: i32, month: u8, day: u8) -> i64 { + // 中文注释:Howard Hinnant civil calendar 算法,返回 1970-01-01 起的日序号。 + let adjusted_year = year - if month <= 2 { 1 } else { 0 }; + let era = adjusted_year.div_euclid(400); + let year_of_era = adjusted_year - era * 400; + let month = i32::from(month); + let day = i32::from(day); + let month_prime = month + if month > 2 { -3 } else { 9 }; + let day_of_year = (153 * month_prime + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + i64::from(era * 146_097 + day_of_era - 719_468) +} + +/// 1970-01-01 起的日序号 → 公历年月日。 +pub fn civil_from_days(date_key: i64) -> (i32, u8, u8) { + // 中文注释:days_from_civil 的反向算法,避免依赖运行环境时区。 + let z = date_key + 719_468; + let era = z.div_euclid(146_097); + let day_of_era = z - era * 146_097; + let year_of_era = (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 + - day_of_era / 146_096) + .div_euclid(365); + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2).div_euclid(153); + let day = day_of_year - (153 * month_prime + 2).div_euclid(5) + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + year += if month <= 2 { 1 } else { 0 }; + (year as i32, month as u8, day as u8) +} + +/// ISO 星期:周一 = 1,周日 = 7。 +pub fn weekday_from_date_key(date_key: i64) -> u8 { + // 中文注释:1970-01-01 是周四;这里返回 ISO weekday,周一=1,周日=7。 + (date_key + 3).rem_euclid(7) as u8 + 1 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn civil_round_trip_holds_across_month_and_year_boundaries() { + for (year, month, day) in [ + (2024, 2, 29), + (2026, 1, 1), + (2026, 12, 31), + (1970, 1, 1), + (2100, 12, 31), + ] { + let date_key = days_from_civil(year, month, day); + assert_eq!(civil_from_days(date_key), (year, month, day)); + } + } + + #[test] + fn days_in_month_applies_leap_year_rules() { + assert_eq!(days_in_month(2024, 2), 29); + assert_eq!(days_in_month(2023, 2), 28); + assert_eq!(days_in_month(2100, 2), 28); + assert_eq!(days_in_month(2000, 2), 29); + assert_eq!(days_in_month(2026, 4), 30); + assert_eq!(days_in_month(2026, 12), 31); + } + + #[test] + fn weekday_from_date_key_matches_known_dates() { + // 中文注释:1970-01-01 是周四;2026-01-01 也是周四。 + assert_eq!(weekday_from_date_key(days_from_civil(1970, 1, 1)), 4); + assert_eq!(weekday_from_date_key(days_from_civil(2026, 1, 1)), 4); + } +} diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index c6698f7a6..ac2ad0863 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -1,5 +1,7 @@ pub mod agc_analytics; mod agc_models; +pub mod civil_calendar; +pub use civil_calendar::*; mod llm_billing; pub use llm_billing::*; mod application; From 969ee329d7d431ddfef40aa0833e7636cea4f68e 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:09:04 +0800 Subject: [PATCH 004/232] =?UTF-8?q?=E9=A2=86=E5=9F=9F=E5=B1=82=EF=BC=9A?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BC=9A=E5=91=98=E8=87=AA=E7=84=B6=E6=9C=88?= =?UTF-8?q?=E8=B4=A6=E6=9C=9F=E4=B8=8E=E5=B9=B6=E5=8F=91=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E7=BA=AF=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - membership_cycle.rs:北京时间锚点解析、自然月推进与月末夹取(1/31 → 2/28 → 3/31,从原始锚点重算)、期窗口、到期日、本期剩余微秒 - membership_limits.rs:并发上限哨兵 MEMBERSHIP_UNLIMITED_CONCURRENCY = 128 与 is_unlimited_concurrency - lib.rs 注册并导出两个模块 - 单测覆盖闰年、跨年、窗口连续性、零索引归一、哨兵判定 - 设计文档同步:membership_cycle_window 去掉无用的 cycle_kind 形参并列出账期推进基元 Co-authored-by: Junie --- ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 9 +- server-rs/crates/module-runtime/src/lib.rs | 4 + .../module-runtime/src/membership_cycle.rs | 185 ++++++++++++++++++ .../module-runtime/src/membership_limits.rs | 31 +++ 4 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 server-rs/crates/module-runtime/src/membership_cycle.rs create mode 100644 server-rs/crates/module-runtime/src/membership_limits.rs diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index a42613790..d1a41128d 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -252,8 +252,13 @@ pub struct RuntimeProfileMembershipUpgradeQuote { 纯函数(无 IO,可单测): -- `membership_cycle_window(anchor_micros, index, cycle_kind) -> (start, reset)`: - 北京时间锚点 + 自然月推进 + 月末夹取,**从原始锚点重算**(1/31 → 2/28 → 3/31)。 +- `membership_cycle_window(anchor_micros, index) -> (start, reset)`: + 北京时间锚点 + 自然月推进 + 月末夹取,**从原始锚点重算**(1/31 → 2/28 → 3/31); + 窗口本身与周期类型无关,总期数由 `cycle_kind.cycle_count()` 给出,`expires_at` 取第 `cycle_count` 期的边界。 +- 账期推进基元(复用 `civil_calendar` 的公历换算,不读系统时区): + `advance_beijing_months_clamped(anchor, months)`、`beijing_local_date_parts(micros) -> (年月日, 当日微秒)`、 + `beijing_local_micros(年月日, 当日微秒)`、`membership_expires_at(anchor, cycle_kind)`、 + `membership_cycle_remaining_micros(resets_at, now)`。 - `quote_membership_upgrade(current, target, now) -> quote`: 金额向上取到分、补点向下取整;月付 / 年付分支见 §6;年价取目录 `year_price_cents`。 - `apply_membership_upgrade(row, quote) -> row'`:`cycle_remaining += 补点`、 diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index ac2ad0863..966ba291b 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -4,6 +4,10 @@ pub mod civil_calendar; pub use civil_calendar::*; mod llm_billing; pub use llm_billing::*; +mod membership_cycle; +pub use membership_cycle::*; +mod membership_limits; +pub use membership_limits::*; mod application; pub use agc_models::*; mod commands; diff --git a/server-rs/crates/module-runtime/src/membership_cycle.rs b/server-rs/crates/module-runtime/src/membership_cycle.rs new file mode 100644 index 000000000..43e76b5e4 --- /dev/null +++ b/server-rs/crates/module-runtime/src/membership_cycle.rs @@ -0,0 +1,185 @@ +//! 会员账期的自然月推进(纯函数,无 IO)。 +//! +//! 账期锚点 = 北京时间(UTC+8)的「开通日时刻」。第 n 期一律从**原始锚点**重算, +//! 而不是从上一期递推,所以 1/31 开通得到的序列是 1/31 → 2/28 → 3/31,不会漂移成 3/28。 +//! 单期长度固定为一个自然月,日期按目标月实际长度夹取(`min(开通日, 目标月天数)`)。 +//! 中国不使用夏令时,因此这里用固定 UTC+8 偏移而不是时区库。 + +use crate::civil_calendar::{civil_from_days, days_from_civil, days_in_month}; +use crate::{PROFILE_RUNTIME_DAY_MICROS, PROFILE_TASK_BEIJING_OFFSET_MICROS}; + +/// 北京时间(UTC+8)某个时刻的本地「年月日 + 当日已过微秒」。 +/// +/// 与 [`runtime_profile_beijing_day_key`](crate::runtime_profile_beijing_day_key) 使用同一套 +/// 固定偏移与业务日边界,保证「同一个业务日」在所有余额投影里口径一致。 +pub fn beijing_local_date_parts(micros: i64) -> (i32, u8, u8, i64) { + let local_micros = micros.saturating_add(PROFILE_TASK_BEIJING_OFFSET_MICROS); + let day_key = local_micros.div_euclid(PROFILE_RUNTIME_DAY_MICROS); + let time_of_day_micros = local_micros.rem_euclid(PROFILE_RUNTIME_DAY_MICROS); + let (year, month, day) = civil_from_days(day_key); + (year, month, day, time_of_day_micros) +} + +/// 北京时间本地年月日 + 当日已过微秒 → UTC 微秒时间戳;本地日期不存在则返回原值。 +pub fn beijing_local_micros(year: i32, month: u8, day: u8, time_of_day_micros: i64) -> i64 { + if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) { + return 0; + } + days_from_civil(year, month, day) + .saturating_mul(PROFILE_RUNTIME_DAY_MICROS) + .saturating_add(time_of_day_micros) + .saturating_sub(PROFILE_TASK_BEIJING_OFFSET_MICROS) +} + +/// 把北京时间锚点推进 `months` 个自然月,日期夹取到目标月最后一天,时刻保持不变。 +/// +/// `months = 0` 返回锚点本身,因此调用方可以安全地用「第 n 期 = 推进 n 个自然月」表达账期。 +pub fn advance_beijing_months_clamped(anchor_micros: i64, months: u32) -> i64 { + let (year, month, day, time_of_day_micros) = beijing_local_date_parts(anchor_micros); + let total_months = i64::from(year) * 12 + i64::from(month) - 1 + i64::from(months); + let target_year = total_months.div_euclid(12) as i32; + let target_month = (total_months.rem_euclid(12) + 1) as u8; + let target_day = day.min(days_in_month(target_year, target_month)); + beijing_local_micros(target_year, target_month, target_day, time_of_day_micros) +} + +/// 第 `index` 期(1-based)的账期窗口 `(cycle_started_at, cycle_resets_at)`。 +/// +/// `index = 1` 是开通当期;`cycle_resets_at` 即下一期开始的时刻。窗口长度与周期类型无关, +/// 年付只是把同样的月度窗口重复 12 次;期数总数由 +/// [`RuntimeProfileMembershipCycleKind::cycle_count`](crate::RuntimeProfileMembershipCycleKind::cycle_count) +/// 给出。 +pub fn membership_cycle_window(anchor_micros: i64, index: u32) -> (i64, i64) { + let index = index.max(1); + let start = if index == 1 { + anchor_micros + } else { + advance_beijing_months_clamped(anchor_micros, index - 1) + }; + let resets_at = advance_beijing_months_clamped(anchor_micros, index); + (start, resets_at) +} + +/// 本次购买的到期时刻:从锚点推进「总期数」个自然月(月付 1 期、年付 12 期)。 +pub fn membership_expires_at( + anchor_micros: i64, + cycle_kind: crate::RuntimeProfileMembershipCycleKind, +) -> i64 { + advance_beijing_months_clamped(anchor_micros, cycle_kind.cycle_count()) +} + +/// 从当前时刻到本期刷新时刻的剩余微秒;已过期返回 0。 +pub fn membership_cycle_remaining_micros(cycle_resets_at_micros: i64, now_micros: i64) -> i64 { + cycle_resets_at_micros.saturating_sub(now_micros).max(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::RuntimeProfileMembershipCycleKind; + + /// 北京时间某时刻 → UTC 微秒。 + fn beijing(year: i32, month: u8, day: u8, hour: i64, minute: i64) -> i64 { + beijing_local_micros(year, month, day, hour * 3_600_000_000 + minute * 60_000_000) + } + + #[test] + fn beijing_local_parts_round_trip() { + let micros = beijing(2026, 10, 2, 9, 20); + assert_eq!( + beijing_local_date_parts(micros), + (2026, 10, 2, 9 * 3_600_000_000 + 20 * 60_000_000) + ); + } + + #[test] + fn advance_months_clamps_to_month_end() { + // 中文注释:1/31 开通 → 2/28(2026 非闰年)→ 3/31,每期都从原始锚点重算。 + let anchor = beijing(2026, 1, 31, 9, 20); + assert_eq!(advance_beijing_months_clamped(anchor, 0), anchor); + assert_eq!( + advance_beijing_months_clamped(anchor, 1), + beijing(2026, 2, 28, 9, 20) + ); + assert_eq!( + advance_beijing_months_clamped(anchor, 2), + beijing(2026, 3, 31, 9, 20) + ); + assert_eq!( + advance_beijing_months_clamped(anchor, 12), + beijing(2027, 1, 31, 9, 20) + ); + } + + #[test] + fn advance_months_handles_leap_year() { + let anchor = beijing(2024, 1, 31, 12, 0); + assert_eq!( + advance_beijing_months_clamped(anchor, 1), + beijing(2024, 2, 29, 12, 0) + ); + assert_eq!( + advance_beijing_months_clamped(anchor, 2), + beijing(2024, 3, 31, 12, 0) + ); + } + + #[test] + fn advance_months_crosses_year_boundary() { + let anchor = beijing(2026, 12, 31, 23, 30); + assert_eq!( + advance_beijing_months_clamped(anchor, 1), + beijing(2027, 1, 31, 23, 30) + ); + assert_eq!( + advance_beijing_months_clamped(anchor, 3), + beijing(2027, 3, 31, 23, 30) + ); + } + + #[test] + fn cycle_window_is_contiguous_from_the_anchor() { + let anchor = beijing(2026, 1, 31, 9, 20); + assert_eq!( + membership_cycle_window(anchor, 1), + (anchor, beijing(2026, 2, 28, 9, 20)) + ); + assert_eq!( + membership_cycle_window(anchor, 2), + (beijing(2026, 2, 28, 9, 20), beijing(2026, 3, 31, 9, 20)) + ); + assert_eq!( + membership_cycle_window(anchor, 3), + (beijing(2026, 3, 31, 9, 20), beijing(2026, 4, 30, 9, 20)) + ); + } + + #[test] + fn cycle_window_treats_zero_index_as_first_period() { + let anchor = beijing(2026, 5, 20, 8, 0); + assert_eq!( + membership_cycle_window(anchor, 0), + membership_cycle_window(anchor, 1) + ); + } + + #[test] + fn expires_at_uses_total_period_count() { + let anchor = beijing(2026, 1, 31, 9, 20); + assert_eq!( + membership_expires_at(anchor, RuntimeProfileMembershipCycleKind::Monthly), + beijing(2026, 2, 28, 9, 20) + ); + assert_eq!( + membership_expires_at(anchor, RuntimeProfileMembershipCycleKind::Yearly), + beijing(2027, 1, 31, 9, 20) + ); + } + + #[test] + fn remaining_micros_never_goes_negative() { + assert_eq!(membership_cycle_remaining_micros(2_000, 1_000), 1_000); + assert_eq!(membership_cycle_remaining_micros(1_000, 1_000), 0); + assert_eq!(membership_cycle_remaining_micros(1_000, 2_000), 0); + } +} diff --git a/server-rs/crates/module-runtime/src/membership_limits.rs b/server-rs/crates/module-runtime/src/membership_limits.rs new file mode 100644 index 000000000..9f22a6ddd --- /dev/null +++ b/server-rs/crates/module-runtime/src/membership_limits.rs @@ -0,0 +1,31 @@ +//! 会员并发上限的判定(纯函数)。 +//! +//! 并发上限用 `u32` 落列与对外暴露,`>= MEMBERSHIP_UNLIMITED_CONCURRENCY`(128)视为「不设上限」。 +//! 刻意不用 `Option`:SpacetimeDB 表列与 DTO 都少一层可空分支,哨兵值只在领域层解释一次。 + +/// 并发上限哨兵值:等于或超过它即视为「不设上限」(`Max` 档)。 +pub const MEMBERSHIP_UNLIMITED_CONCURRENCY: u32 = 128; + +/// 该并发上限是否表示「不设上限」。 +pub fn is_unlimited_concurrency(limit: u32) -> bool { + limit >= MEMBERSHIP_UNLIMITED_CONCURRENCY +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ordinary_limits_are_not_unlimited() { + for limit in [1, 3, 5, 127] { + assert!(!is_unlimited_concurrency(limit)); + } + } + + #[test] + fn sentinel_and_above_are_unlimited() { + for limit in [MEMBERSHIP_UNLIMITED_CONCURRENCY, 129, u32::MAX] { + assert!(is_unlimited_concurrency(limit)); + } + } +} 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 005/232] =?UTF-8?q?=E9=A2=86=E5=9F=9F=E5=B1=82=EF=BC=9A?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BC=9A=E5=91=98=E8=AE=A1=E5=88=92=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E7=A7=8D=E5=AD=90=E4=B8=8E=E6=A1=A3=E4=BD=8D=E8=A7=A3?= =?UTF-8?q?=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)); + } + } +} From 2b77fa08c301d0c39e7bb2d29b1962f7dea6e9a9 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:11:54 +0800 Subject: [PATCH 006/232] =?UTF-8?q?=E9=A2=86=E5=9F=9F=E5=B1=82=EF=BC=9A?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BC=9A=E5=91=98=E8=A1=A5=E5=B7=AE=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E6=8A=A5=E4=BB=B7=E7=BA=AF=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 membership_upgrade.rs:quote_runtime_profile_membership_upgrade 与 RuntimeProfileMembershipUpgradeQuoteInput / RuntimeProfileMembershipUpgradeQuote - 月付按「新月价 − 当前月价」「新每期额度 − 当前每期额度」;年付按 (新年价 − 当前年价)/12×(后续完整月数 + 本期剩余比例) - check_runtime_profile_membership_upgrade_allowed 实现允许操作矩阵:非会员 / 周期变更 / 同档降档 / 目标不可购买一律拒绝 - membership_cycle_remaining_ratio_ppm 计算本期剩余比例(ppm,向下取整) - 金额向上取到分、补点向下取整;有效期 / 刷新日 / 期数保持不变 - 单测复现 spec 示例:Plus 月→Pro 月 ¥200 / 2500 点 / 余额 3115;Plus 年→Pro 年 ¥1083.34 / 1250 点 / 余额 1865 - 设计文档同步报价函数签名与「后续完整月数 = cycle_count − cycle_index」口径 Co-authored-by: Junie --- ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 10 +- server-rs/crates/module-runtime/src/lib.rs | 2 + .../module-runtime/src/membership_upgrade.rs | 393 ++++++++++++++++++ 3 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 server-rs/crates/module-runtime/src/membership_upgrade.rs diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index cdb9c9fbd..00e7ca41f 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -269,8 +269,14 @@ pub struct RuntimeProfileMembershipUpgradeQuote { `advance_beijing_months_clamped(anchor, months)`、`beijing_local_date_parts(micros) -> (年月日, 当日微秒)`、 `beijing_local_micros(年月日, 当日微秒)`、`membership_expires_at(anchor, cycle_kind)`、 `membership_cycle_remaining_micros(resets_at, now)`。 -- `quote_membership_upgrade(current, target, now) -> quote`: - 金额向上取到分、补点向下取整;月付 / 年付分支见 §6;年价取目录 `year_price_cents`。 +- `quote_runtime_profile_membership_upgrade(input) -> Result`: + 入参 `RuntimeProfileMembershipUpgradeQuoteInput`(当前档位 / 周期、目标档位 / 周期、期序号 / 期总数、 + 本期窗口、当前月度余额、`now`);金额向上取到分、补点向下取整;月付 / 年付分支见 §6;年价取目录 `year_price_cents`。 +- `check_runtime_profile_membership_upgrade_allowed(...) -> Result<(), rejection>`: + §3.4 允许操作矩阵的唯一实现(非会员不可升级 / 不支持月转年与年转月 / 同档与降档拒绝 / 目标不可购买拒绝)。 +- `membership_cycle_remaining_ratio_ppm(cycle_started_at, cycle_resets_at, now) -> u32`:本期剩余比例(ppm,向下取整)。 +- **「后续完整月数」= `cycle_count − cycle_index`**,不从 `cycle_resets_at` 重新锚定推算: + 月末夹取后的日期(1/31 锚点得到 2/28)不再是原始开通日,重锚会把后续月份漂移成 3/28。 - `apply_membership_upgrade(row, quote) -> row'`:`cycle_remaining += 补点`、 `cycle_granted_points += 补点`、`plan` 更新,刷新日与到期日不变。 - `is_unlimited_concurrency(limit) -> bool`:`limit >= 128` 视为不设上限(哨兵值集中在领域层一处)。 diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 3c905bf55..2cb68ce6b 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -10,6 +10,8 @@ mod membership_limits; pub use membership_limits::*; mod membership_catalog; pub use membership_catalog::*; +mod membership_upgrade; +pub use membership_upgrade::*; mod application; pub use agc_models::*; mod commands; diff --git a/server-rs/crates/module-runtime/src/membership_upgrade.rs b/server-rs/crates/module-runtime/src/membership_upgrade.rs new file mode 100644 index 000000000..029f7274f --- /dev/null +++ b/server-rs/crates/module-runtime/src/membership_upgrade.rs @@ -0,0 +1,393 @@ +//! 会员补差升级报价(纯函数,无 IO)。 +//! +//! 规则来自实施计划 §3.3,并且**金额与取整只有这一处实现**:报价接口与下单重算必须调用同一个函数, +//! 逐字段一致,前端展示价不是真相源。 +//! +//! - 月付:`应付 = 新月价 − 当前月价`,`补点 = 新每期额度 − 当前每期额度`(不按时间折算)。 +//! - 年付:`应付 = (新年价 − 当前年价) / 12 × (后续完整月数 + 本期剩余比例)`, +//! `补点 = (新每期额度 − 当前每期额度) × 本期剩余比例`。 +//! - 取整:金额**向上**取到分,补点**向下**取整。 +//! - 有效期、刷新日、期数都不变;已用点不返还,永久泥点不动。 +//! +//! 「后续完整月数」取 `cycle_count − cycle_index`,**不**从 `cycle_resets_at` 重新锚定推算: +//! 月末夹取后的日期(如 1/31 锚点得到 2/28)不再是原始开通日,重锚会漂移成 3/28。 +//! 「本期剩余比例」用本期实际时长做分母,单位取百万分之一(ppm)。 + +use serde::{Deserialize, Serialize}; + +use crate::membership_catalog::resolve_runtime_profile_membership_plan_snapshot; +use crate::membership_cycle::membership_cycle_remaining_micros; +use crate::{RuntimeProfileMembershipCycleKind, RuntimeProfileMembershipPlan}; + +/// 比例分母:百万分之一。 +const REMAINING_RATIO_PPM: u128 = 1_000_000; +/// 年付按 12 个自然月折算。 +const MONTHS_PER_YEAR: u128 = 12; + +/// 报价接口与下单重算的共用输入。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipUpgradeQuoteInput { + pub current_plan: RuntimeProfileMembershipPlan, + pub current_cycle_kind: RuntimeProfileMembershipCycleKind, + pub target_plan: RuntimeProfileMembershipPlan, + pub target_cycle_kind: RuntimeProfileMembershipCycleKind, + /// 当前期序号(1-based)。 + pub cycle_index: u32, + /// 本次购买总期数(月付 1 / 年付 12)。 + pub cycle_count: u32, + pub cycle_started_at_micros: i64, + pub cycle_resets_at_micros: i64, + pub expires_at_micros: i64, + pub cycle_remaining_points: u64, + pub now_micros: i64, +} + +/// 补差升级报价结果;有效期与刷新日与变更前一致。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +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, +} + +/// 升级被拒绝的原因;对外应映射为 4xx 与可读文案,而不是静默算出金额。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileMembershipUpgradeRejection { + /// 当前不是会员:应走下单购买流程,而不是补差升级。 + NotAMember, + /// 月转年 / 年转月:本期不支持即时转换,到期后按完整价格重新选择。 + CycleKindChangeUnsupported, + /// 同档位(重复购买)或降档:不允许,到期后再重新选择。 + NotUpgrade, + /// 目标档位不可购买(非会员占位或已下架)。 + TargetPlanNotPurchasable, + /// 目录缺少对应档位 / 周期的价格。 + MissingCatalogEntry, +} + +impl RuntimeProfileMembershipUpgradeRejection { + pub fn as_str(&self) -> &'static str { + match self { + Self::NotAMember => "not_a_member", + Self::CycleKindChangeUnsupported => "cycle_kind_change_unsupported", + Self::NotUpgrade => "not_upgrade", + Self::TargetPlanNotPurchasable => "target_plan_not_purchasable", + Self::MissingCatalogEntry => "missing_catalog_entry", + } + } +} + +/// 本期剩余比例(ppm):`剩余微秒 / 本期实际微秒`,向下取整,已过期或时长非法时为 0。 +pub fn membership_cycle_remaining_ratio_ppm( + cycle_started_at_micros: i64, + cycle_resets_at_micros: i64, + now_micros: i64, +) -> u32 { + let total_micros = cycle_resets_at_micros.saturating_sub(cycle_started_at_micros); + if total_micros <= 0 { + return 0; + } + let remaining_micros = membership_cycle_remaining_micros(cycle_resets_at_micros, now_micros); + let remaining_micros = remaining_micros.min(total_micros); + (u128::from(remaining_micros as u64) * REMAINING_RATIO_PPM / u128::from(total_micros as u64)) + as u32 +} + +/// 允许操作矩阵(实施计划 §3.4):只有「已是会员 + 同周期 + 升更高档位 + 目标可购买」才允许升级。 +pub fn check_runtime_profile_membership_upgrade_allowed( + current_plan: RuntimeProfileMembershipPlan, + target_plan: RuntimeProfileMembershipPlan, + current_cycle_kind: RuntimeProfileMembershipCycleKind, + target_cycle_kind: RuntimeProfileMembershipCycleKind, +) -> Result<(), RuntimeProfileMembershipUpgradeRejection> { + if !current_plan.is_purchasable() { + return Err(RuntimeProfileMembershipUpgradeRejection::NotAMember); + } + if current_cycle_kind != target_cycle_kind { + return Err(RuntimeProfileMembershipUpgradeRejection::CycleKindChangeUnsupported); + } + if crate::runtime_profile_membership_plan_rank(target_plan) + <= crate::runtime_profile_membership_plan_rank(current_plan) + { + return Err(RuntimeProfileMembershipUpgradeRejection::NotUpgrade); + } + if !crate::is_runtime_profile_membership_plan_purchasable(target_plan) { + return Err(RuntimeProfileMembershipUpgradeRejection::TargetPlanNotPurchasable); + } + Ok(()) +} + +/// 计算补差升级报价;报价与下单重算必须共用本函数。 +pub fn quote_runtime_profile_membership_upgrade( + input: RuntimeProfileMembershipUpgradeQuoteInput, +) -> Result { + check_runtime_profile_membership_upgrade_allowed( + input.current_plan, + input.target_plan, + input.current_cycle_kind, + input.target_cycle_kind, + )?; + + let current = resolve_runtime_profile_membership_plan_snapshot( + input.current_plan, + input.current_cycle_kind, + ) + .ok_or(RuntimeProfileMembershipUpgradeRejection::MissingCatalogEntry)?; + let target = resolve_runtime_profile_membership_plan_snapshot( + input.target_plan, + input.target_cycle_kind, + ) + .ok_or(RuntimeProfileMembershipUpgradeRejection::MissingCatalogEntry)?; + + let remaining_full_months = input.cycle_count.saturating_sub(input.cycle_index); + let remaining_ratio_ppm = membership_cycle_remaining_ratio_ppm( + input.cycle_started_at_micros, + input.cycle_resets_at_micros, + input.now_micros, + ); + + let price_delta_cents = target.price_cents.saturating_sub(current.price_cents); + let points_delta = target.period_points.saturating_sub(current.period_points); + + let (amount_cents, granted_points_delta) = match input.target_cycle_kind { + RuntimeProfileMembershipCycleKind::Monthly => (price_delta_cents, points_delta), + RuntimeProfileMembershipCycleKind::Yearly => { + let weighted_months_ppm = u128::from(remaining_full_months) * REMAINING_RATIO_PPM + + u128::from(remaining_ratio_ppm); + let amount_cents = ceil_div( + u128::from(price_delta_cents) * weighted_months_ppm, + MONTHS_PER_YEAR * REMAINING_RATIO_PPM, + ); + let granted_points_delta = (u128::from(points_delta) * u128::from(remaining_ratio_ppm) + / REMAINING_RATIO_PPM) as u64; + (amount_cents, granted_points_delta) + } + }; + + Ok(RuntimeProfileMembershipUpgradeQuote { + amount_cents, + granted_points_delta, + monthly_balance_after: input + .cycle_remaining_points + .saturating_add(granted_points_delta), + expires_at_micros: input.expires_at_micros, + cycle_resets_at_micros: input.cycle_resets_at_micros, + cycle_index: input.cycle_index, + remaining_full_months, + remaining_ratio_ppm, + }) +} + +/// 向上取整的整数除法(金额取整到分)。 +fn ceil_div(numerator: u128, denominator: u128) -> u64 { + if denominator == 0 { + return 0; + } + ((numerator + denominator - 1) / denominator) as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::membership_cycle::beijing_local_micros; + + const DAY_MICROS: i64 = 86_400_000_000; + + fn beijing(year: i32, month: u8, day: u8, hour: i64) -> i64 { + beijing_local_micros(year, month, day, hour * 3_600_000_000) + } + + fn base_input() -> RuntimeProfileMembershipUpgradeQuoteInput { + let cycle_started_at_micros = beijing(2026, 6, 20, 9); + let cycle_resets_at_micros = beijing(2026, 7, 20, 9); + RuntimeProfileMembershipUpgradeQuoteInput { + current_plan: RuntimeProfileMembershipPlan::Plus, + current_cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, + target_plan: RuntimeProfileMembershipPlan::Pro, + target_cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, + cycle_index: 3, + cycle_count: 9, + cycle_started_at_micros, + cycle_resets_at_micros, + expires_at_micros: beijing(2027, 3, 20, 9), + cycle_remaining_points: 615, + now_micros: cycle_started_at_micros + + (cycle_resets_at_micros - cycle_started_at_micros) / 2, + } + } + + #[test] + fn monthly_upgrade_matches_the_spec_example() { + // 中文注释:Plus 月 → Pro 月:补付 ¥200、补点 2500、余额 615 + 2500 = 3115。 + let quote = + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current_cycle_kind: RuntimeProfileMembershipCycleKind::Monthly, + target_cycle_kind: RuntimeProfileMembershipCycleKind::Monthly, + cycle_index: 1, + cycle_count: 1, + cycle_remaining_points: 615, + ..base_input() + }) + .expect("月付升级应可报价"); + + assert_eq!(quote.amount_cents, 20_000); + assert_eq!(quote.granted_points_delta, 2_500); + assert_eq!(quote.monthly_balance_after, 3_115); + assert_eq!(quote.remaining_full_months, 0); + } + + #[test] + fn yearly_upgrade_matches_the_spec_example() { + // 中文注释:Plus 年 → Pro 年,剩 6 个完整月 + 半个当前月 → 补付 ¥1083.34、补点 1250。 + let quote = + quote_runtime_profile_membership_upgrade(base_input()).expect("年付升级应可报价"); + + assert_eq!(quote.amount_cents, 108_334); + assert_eq!(quote.granted_points_delta, 1_250); + assert_eq!(quote.monthly_balance_after, 1_865); + assert_eq!(quote.remaining_full_months, 6); + assert_eq!(quote.remaining_ratio_ppm, 500_000); + } + + #[test] + fn upgrade_keeps_expiry_reset_and_cycle_index() { + let input = base_input(); + let quote = quote_runtime_profile_membership_upgrade(input).expect("年付升级应可报价"); + assert_eq!(quote.expires_at_micros, input.expires_at_micros); + assert_eq!(quote.cycle_resets_at_micros, input.cycle_resets_at_micros); + assert_eq!(quote.cycle_index, input.cycle_index); + } + + #[test] + fn remaining_ratio_is_zero_at_or_after_reset() { + let input = base_input(); + assert_eq!( + membership_cycle_remaining_ratio_ppm( + input.cycle_started_at_micros, + input.cycle_resets_at_micros, + input.cycle_resets_at_micros + ), + 0 + ); + assert_eq!( + membership_cycle_remaining_ratio_ppm( + input.cycle_started_at_micros, + input.cycle_resets_at_micros, + input.cycle_resets_at_micros + DAY_MICROS + ), + 0 + ); + + let quote = + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + now_micros: input.cycle_resets_at_micros + DAY_MICROS, + ..input + }) + .expect("年付升级应可报价"); + assert_eq!(quote.remaining_ratio_ppm, 0); + assert_eq!(quote.granted_points_delta, 0); + // 中文注释:仅剩 6 个完整月,本期零头为 0,因此只收 6/12 的价差并向下取整补点。 + assert_eq!(quote.amount_cents, 100_000); + } + + #[test] + fn non_member_must_purchase_instead_of_upgrading() { + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current_plan: RuntimeProfileMembershipPlan::Normal, + target_plan: RuntimeProfileMembershipPlan::Starter, + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::NotAMember) + ); + } + + #[test] + fn same_plan_and_downgrade_are_rejected() { + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + target_plan: RuntimeProfileMembershipPlan::Plus, + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::NotUpgrade) + ); + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current_plan: RuntimeProfileMembershipPlan::Pro, + target_plan: RuntimeProfileMembershipPlan::Plus, + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::NotUpgrade) + ); + } + + #[test] + fn cycle_kind_change_is_rejected() { + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current_cycle_kind: RuntimeProfileMembershipCycleKind::Monthly, + target_cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::CycleKindChangeUnsupported) + ); + } + + #[test] + fn continuing_upgrade_compares_against_the_current_plan() { + // 中文注释:Starter → Plus → Pro 时,第二次按 Plus vs Pro 计算(¥1083.34), + // 而不是按 Starter 直接跳 Pro(¥1408.34)。 + let starter_to_plus = + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current_plan: RuntimeProfileMembershipPlan::Starter, + target_plan: RuntimeProfileMembershipPlan::Plus, + cycle_remaining_points: 0, + ..base_input() + }) + .expect("Starter → Plus 应可报价"); + let starter_to_pro = + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current_plan: RuntimeProfileMembershipPlan::Starter, + target_plan: RuntimeProfileMembershipPlan::Pro, + cycle_remaining_points: 0, + ..base_input() + }) + .expect("Starter → Pro 应可报价"); + let plus_to_pro = + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + cycle_remaining_points: 0, + ..base_input() + }) + .expect("Plus → Pro 应可报价"); + + assert_eq!(starter_to_plus.amount_cents, 32_500); + assert_eq!(starter_to_pro.amount_cents, 140_834); + assert_eq!(plus_to_pro.amount_cents, 108_334); + assert_ne!(starter_to_pro.amount_cents, plus_to_pro.amount_cents); + } + + #[test] + fn yearly_amount_rounds_up_to_cent_and_points_round_down() { + // 中文注释:本期实际时长 3 天、已过 2 天 → 剩余比例 333_333 ppm(向下取整); + // 金额 200000 × (6 + 0.333333) / 12 = 105555.55 分 → 向上到 105556; + // 补点 2500 × 0.333333 = 833.33 → 向下取整为 833。 + let started = beijing(2026, 6, 20, 9); + let quote = + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + cycle_started_at_micros: started, + cycle_resets_at_micros: started + 3 * DAY_MICROS, + now_micros: started + 2 * DAY_MICROS, + ..base_input() + }) + .expect("年付升级应可报价"); + + assert_eq!(quote.remaining_ratio_ppm, 333_333); + assert_eq!(quote.amount_cents, 105_556); + assert_eq!(quote.granted_points_delta, 833); + } +} From ba22d178d3f66ca29547b29137060553de8975f0 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:21:01 +0800 Subject: [PATCH 007/232] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E4=BC=9A?= =?UTF-8?q?=E5=91=98=E9=A2=86=E5=9F=9F=E6=A8=A1=E5=9D=97=E5=BD=92=E5=B9=B6?= =?UTF-8?q?=E5=88=B0=20membership=20=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 把 membership_catalog/cycle/limits/upgrade 四个文件移入 src/membership/ 并去掉公共前缀 - 新增 membership/mod.rs 统一声明子模块并按依赖顺序导出 - lib.rs 改为 pub mod membership,删除旧的四个模块声明 - membership/upgrade.rs 内部引用改为同级模块路径 - 无行为变化,cargo test -p module-runtime 全通过 Co-authored-by: Junie --- server-rs/crates/module-runtime/src/lib.rs | 10 ++-------- .../catalog.rs} | 0 .../cycle.rs} | 0 .../limits.rs} | 0 .../module-runtime/src/membership/mod.rs | 20 +++++++++++++++++++ .../upgrade.rs} | 6 +++--- 6 files changed, 25 insertions(+), 11 deletions(-) rename server-rs/crates/module-runtime/src/{membership_catalog.rs => membership/catalog.rs} (100%) rename server-rs/crates/module-runtime/src/{membership_cycle.rs => membership/cycle.rs} (100%) rename server-rs/crates/module-runtime/src/{membership_limits.rs => membership/limits.rs} (100%) create mode 100644 server-rs/crates/module-runtime/src/membership/mod.rs rename server-rs/crates/module-runtime/src/{membership_upgrade.rs => membership/upgrade.rs} (98%) diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 2cb68ce6b..bcec66d2e 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -4,15 +4,8 @@ pub mod civil_calendar; pub use civil_calendar::*; mod llm_billing; pub use llm_billing::*; -mod membership_cycle; -pub use membership_cycle::*; -mod membership_limits; -pub use membership_limits::*; -mod membership_catalog; -pub use membership_catalog::*; -mod membership_upgrade; -pub use membership_upgrade::*; mod application; +pub mod membership; pub use agc_models::*; mod commands; mod domain; @@ -23,6 +16,7 @@ pub use application::*; pub use commands::*; pub use domain::*; pub use errors::*; +pub use membership::*; use shared_kernel::format_rfc3339 as format_shared_rfc3339; use time::OffsetDateTime; diff --git a/server-rs/crates/module-runtime/src/membership_catalog.rs b/server-rs/crates/module-runtime/src/membership/catalog.rs similarity index 100% rename from server-rs/crates/module-runtime/src/membership_catalog.rs rename to server-rs/crates/module-runtime/src/membership/catalog.rs diff --git a/server-rs/crates/module-runtime/src/membership_cycle.rs b/server-rs/crates/module-runtime/src/membership/cycle.rs similarity index 100% rename from server-rs/crates/module-runtime/src/membership_cycle.rs rename to server-rs/crates/module-runtime/src/membership/cycle.rs diff --git a/server-rs/crates/module-runtime/src/membership_limits.rs b/server-rs/crates/module-runtime/src/membership/limits.rs similarity index 100% rename from server-rs/crates/module-runtime/src/membership_limits.rs rename to server-rs/crates/module-runtime/src/membership/limits.rs diff --git a/server-rs/crates/module-runtime/src/membership/mod.rs b/server-rs/crates/module-runtime/src/membership/mod.rs new file mode 100644 index 000000000..5edfeeb37 --- /dev/null +++ b/server-rs/crates/module-runtime/src/membership/mod.rs @@ -0,0 +1,20 @@ +//! 会员计费领域层(纯函数,无 IO)。 +//! +//! 只放会员侧的规则计算:档位目录与解析、自然月账期推进、并发上限哨兵与补差升级报价。 +//! SpacetimeDB 写入与事务编排留在 `spacetime-module`,HTTP 契约留在 `api-server`。 +//! +//! 子模块按依赖顺序排列: +//! - [`catalog`]:档位枚举 → 可下单套餐(价格 / 每期泥点 / 模型权限 / 并发)。 +//! - [`cycle`]:北京时间自然月账期(1/31 → 2/28 → 3/31,每期从原始锚点重算)。 +//! - [`limits`]:并发上限哨兵,`>= 128` 视为「不设上限」。 +//! - [`upgrade`]:补差升级报价与允许操作矩阵。 + +mod catalog; +mod cycle; +mod limits; +mod upgrade; + +pub use catalog::*; +pub use cycle::*; +pub use limits::*; +pub use upgrade::*; diff --git a/server-rs/crates/module-runtime/src/membership_upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs similarity index 98% rename from server-rs/crates/module-runtime/src/membership_upgrade.rs rename to server-rs/crates/module-runtime/src/membership/upgrade.rs index 029f7274f..3f5661eba 100644 --- a/server-rs/crates/module-runtime/src/membership_upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -15,8 +15,8 @@ use serde::{Deserialize, Serialize}; -use crate::membership_catalog::resolve_runtime_profile_membership_plan_snapshot; -use crate::membership_cycle::membership_cycle_remaining_micros; +use super::catalog::resolve_runtime_profile_membership_plan_snapshot; +use super::cycle::membership_cycle_remaining_micros; use crate::{RuntimeProfileMembershipCycleKind, RuntimeProfileMembershipPlan}; /// 比例分母:百万分之一。 @@ -194,7 +194,7 @@ fn ceil_div(numerator: u128, denominator: u128) -> u64 { #[cfg(test)] mod tests { use super::*; - use crate::membership_cycle::beijing_local_micros; + use crate::membership::cycle::beijing_local_micros; const DAY_MICROS: i64 = 86_400_000_000; From 26e1ea9e2711c878abf5b8750b11e700b5efb1fd 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:33:16 +0800 Subject: [PATCH 008/232] =?UTF-8?q?feat(=E6=B3=A5=E7=82=B9):=20=E8=B4=A6?= =?UTF-8?q?=E6=9C=AC=E6=9D=A5=E6=BA=90=E8=BF=BD=E5=8A=A0=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E8=A1=A5=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module-runtime:RuntimeProfileWalletLedgerSourceType 末尾追加 MembershipUpgradeGrant,保持枚举只追加 - shared-contracts:新增 PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_UPGRADE_GRANT 常量 - api-server:后台枚举索引映射与来源格式化支持新来源,并补单测断言 - spacetime-client:手写 mapper 与生成绑定同步新增变体 - packages/shared 与主站前端:钱包流水来源标签新增「会员升级补点」 Co-authored-by: Junie --- .../PlatformProfileWalletLedgerModal/index.test.tsx | 3 +++ .../PlatformProfileWalletLedgerModal/model.ts | 1 + packages/shared/src/contracts/runtime.ts | 1 + server-rs/crates/api-server/src/admin.rs | 9 +++++++++ server-rs/crates/api-server/src/runtime_profile.rs | 10 ++++++++++ server-rs/crates/module-runtime/src/domain.rs | 2 ++ server-rs/crates/module-runtime/src/lib.rs | 4 ++++ server-rs/crates/shared-contracts/src/runtime.rs | 2 ++ .../src/active/mapper/runtime_profile.rs | 3 +++ .../runtime_profile_wallet_ledger_source_type_type.rs | 2 ++ .../platform-entry/platformProfileFundsModel.ts | 1 + 11 files changed, 38 insertions(+) diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx index 2c3369106..c734e3ff7 100644 --- a/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx @@ -103,6 +103,9 @@ test('builds wallet ledger presentation with stable source fallbacks', () => { ); expect(getWalletLedgerSourceLabel('future_source')).toBe('future_source'); expect(getWalletLedgerSourceLabel('llm_router_consume')).toBe('LLM 调用消耗'); + expect(getWalletLedgerSourceLabel('membership_upgrade_grant')).toBe( + '会员升级补点', + ); expect(getWalletLedgerSourceLabel('')).toBe('未知来源'); expect(formatWalletLedgerDate('not-a-date')).toBe('not-a-date'); diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts index a18adce79..dd81ffc83 100644 --- a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts @@ -11,6 +11,7 @@ const PROFILE_WALLET_LEDGER_SOURCE_LABELS = { snapshot_sync: '账户同步', membership_period_grant: '会员周期发放', membership_period_reset: '会员周期重置', + membership_upgrade_grant: '会员升级补点', daily_free_grant: '每日免费发放', daily_free_reset: '每日免费重置', asset_operation_consume: '资产操作消耗', diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index eee7978ed..2d30cc81d 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -74,6 +74,7 @@ export type ProfileWalletLedgerEntry = { | 'points_recharge' | 'membership_period_grant' | 'membership_period_reset' + | 'membership_upgrade_grant' | 'daily_free_grant' | 'daily_free_reset' | 'asset_operation_consume' diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index eb4b766ac..5b3e3afc5 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -4766,6 +4766,7 @@ fn wallet_ledger_source_type_to_string(value: &Value) -> Option { 13 => "daily_free_reset", 14 => "recharge_refund_recovery", 15 => "llm_router_consume", + 16 => "membership_upgrade_grant", _ => return Some(Value::Array(items.to_vec()).to_string()), } .to_string(), @@ -6277,6 +6278,14 @@ mod tests { wallet_ledger_source_type_to_string(&json!([15, []])).as_deref(), Some("llm_router_consume") ); + assert_eq!( + wallet_ledger_source_type_to_string(&json!([16, []])).as_deref(), + Some("membership_upgrade_grant") + ); + assert_eq!( + wallet_ledger_source_type_to_string(&json!([17, []])).as_deref(), + Some("[17,[]]") + ); } #[test] diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index db27bb20f..d92ae2454 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -55,6 +55,7 @@ use shared_contracts::runtime::{ PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_PERIOD_GRANT, PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_PERIOD_RESET, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_UPGRADE_GRANT, PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM, @@ -214,6 +215,9 @@ fn format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_RECHARGE_REFUND_RECOVERY } + RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_UPGRADE_GRANT + } } } @@ -2649,6 +2653,12 @@ mod tests { ), shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_RESET ); + assert_eq!( + format_profile_wallet_ledger_source_type( + RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant + ), + shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_UPGRADE_GRANT + ); } #[test] diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 9d60e5cef..be3856223 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1152,6 +1152,7 @@ pub enum RuntimeProfileWalletLedgerSourceType { DailyFreeReset, RechargeRefundRecovery, LlmRouterConsume, + MembershipUpgradeGrant, } impl RuntimeProfileWalletLedgerSourceType { @@ -1173,6 +1174,7 @@ impl RuntimeProfileWalletLedgerSourceType { Self::DailyFreeReset => "daily_free_reset", Self::RechargeRefundRecovery => "recharge_refund_recovery", Self::LlmRouterConsume => "llm_router_consume", + Self::MembershipUpgradeGrant => "membership_upgrade_grant", } } } diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index bcec66d2e..8c0ddb80a 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -939,6 +939,10 @@ mod tests { RuntimeProfileWalletLedgerSourceType::LlmRouterConsume.as_str(), "llm_router_consume" ); + assert_eq!( + RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant.as_str(), + "membership_upgrade_grant" + ); } #[test] diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 704a5d444..ac339f8ac 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -26,6 +26,8 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD: &str = "redeem_c pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM: &str = "puzzle_author_incentive_claim"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD: &str = "daily_task_reward"; +pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_UPGRADE_GRANT: &str = + "membership_upgrade_grant"; pub const PROFILE_TASK_CYCLE_DAILY: &str = "daily"; pub const PROFILE_TASK_STATUS_INCOMPLETE: &str = "incomplete"; pub const PROFILE_TASK_STATUS_CLAIMABLE: &str = "claimable"; diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs index cc73d695d..b92650e66 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs @@ -70,6 +70,9 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back( crate::module_bindings::RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery => { module_runtime::RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant => { + module_runtime::RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant + } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs index ad94b2707..ad3437f63 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs @@ -39,6 +39,8 @@ pub enum RuntimeProfileWalletLedgerSourceType { RechargeRefundRecovery, LlmRouterConsume, + + MembershipUpgradeGrant, } impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType { diff --git a/src/components/platform-entry/platformProfileFundsModel.ts b/src/components/platform-entry/platformProfileFundsModel.ts index 2875aa7af..e2d92f761 100644 --- a/src/components/platform-entry/platformProfileFundsModel.ts +++ b/src/components/platform-entry/platformProfileFundsModel.ts @@ -11,6 +11,7 @@ const PLATFORM_WALLET_LEDGER_SOURCE_LABELS = { snapshot_sync: '账户同步', membership_period_grant: '会员周期发放', membership_period_reset: '会员周期重置', + membership_upgrade_grant: '会员升级补点', daily_free_grant: '每日免费发放', daily_free_reset: '每日免费重置', asset_operation_consume: '资产操作消耗', From f6fbd2a39248e436f19541ff720e0326eb9c67ff 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:36:16 +0800 Subject: [PATCH 009/232] =?UTF-8?q?chore(=E4=BC=9A=E5=91=98):=20=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E6=9C=AA=E8=A2=AB=E5=BC=95=E7=94=A8=E7=9A=84=E6=97=A7?= =?UTF-8?q?=E8=A1=A8=E5=AE=9A=E4=B9=89=E5=89=AF=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spacetime-module:删除 src/runtime/profile.rs,它未被 src/runtime.rs 挂载(真正生效的是 runtime/active/profile.rs) - 避免设计文档与实现指向失效文件,也避免后续删列改动误落两份 Co-authored-by: Junie --- .../spacetime-module/src/runtime/profile.rs | 10139 ---------------- 1 file changed, 10139 deletions(-) delete mode 100644 server-rs/crates/spacetime-module/src/runtime/profile.rs diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs deleted file mode 100644 index 762ae0a5c..000000000 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ /dev/null @@ -1,10139 +0,0 @@ -use crate::*; -use std::collections::{HashMap, HashSet, VecDeque}; - -const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000; -const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7; -const PROFILE_REFERRAL_INVITED_USERS_LIMIT: usize = 20; -const PROFILE_NEW_USER_REGISTRATION_LEDGER_PREFIX: &str = "new-user-registration"; -const PROFILE_TASK_SYSTEM_USER_ID: &str = "system:profile-task"; -const PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID: &str = "system:recharge-product"; -const PROFILE_TASK_LOGIN_EVENT_ID_PREFIX: &str = "daily-login"; -const PROFILE_TRACKING_PROFILE_MODULE_KEY: &str = "profile"; -const PROFILE_RECHARGE_ORDER_EXPIRATION_CLAIM_LIMIT_DEFAULT: u32 = 20; -const PROFILE_RECHARGE_ORDER_EXPIRATION_CLAIM_LIMIT_MAX: u32 = 50; -const PROFILE_RECHARGE_ORDER_EXPIRATION_CHECK_LIMIT_DEFAULT: u32 = 50; -const PROFILE_RECHARGE_ORDER_EXPIRATION_CHECK_LIMIT_MAX: u32 = 200; -const ASSET_OPERATION_CONSUME_LEDGER_PREFIX: &str = "asset_operation_consume:"; -const ASSET_OPERATION_REFUND_LEDGER_PREFIX: &str = "asset_operation_refund:"; - -#[spacetimedb::table(accessor = profile_dashboard_state)] -pub struct ProfileDashboardState { - #[primary_key] - pub(crate) user_id: String, - pub(crate) wallet_balance: u64, - pub(crate) total_play_time_ms: u64, - pub(crate) created_at: Timestamp, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table(accessor = profile_daily_free_points)] -#[derive(Clone)] -pub struct ProfileDailyFreePoints { - #[primary_key] - pub(crate) user_id: String, - pub(crate) day_key: i64, - pub(crate) granted_points: u64, - pub(crate) remaining_points: u64, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_wallet_ledger, - index(accessor = by_profile_wallet_ledger_user_id, btree(columns = [user_id])), - index( - accessor = by_profile_wallet_ledger_user_created_at, - btree(columns = [user_id, created_at]) - ) -)] -pub struct ProfileWalletLedger { - #[primary_key] - pub(crate) wallet_ledger_id: String, - pub(crate) user_id: String, - pub(crate) amount_delta: i64, - pub(crate) balance_after: u64, - pub(crate) source_type: RuntimeProfileWalletLedgerSourceType, - pub(crate) created_at: Timestamp, - #[default(None::)] - pub(crate) metadata_json: Option, -} - -#[spacetimedb::table(accessor = asset_operation_wallet_settlement)] -#[derive(Clone)] -pub struct AssetOperationWalletSettlement { - #[primary_key] - pub(crate) consume_ledger_id: String, - pub(crate) refund_ledger_id: String, - pub(crate) user_id: String, - pub(crate) amount: u64, - pub(crate) settled_at: Timestamp, -} - -#[spacetimedb::table(accessor = profile_wallet_config)] -#[derive(Clone)] -pub struct ProfileWalletConfig { - #[primary_key] - pub(crate) config_id: String, - pub(crate) initial_mud_points: u64, - pub(crate) created_by: String, - pub(crate) created_at: Timestamp, - pub(crate) updated_by: String, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = tracking_event, - index(accessor = by_tracking_event_event_key, btree(columns = [event_key])), - index( - accessor = by_tracking_event_scope, - btree(columns = [scope_kind, scope_id]) - ), - index( - accessor = by_tracking_event_user, - btree(columns = [user_id, occurred_at]) - ) -)] -pub struct TrackingEvent { - #[primary_key] - pub(crate) event_id: String, - pub(crate) event_key: String, - pub(crate) scope_kind: RuntimeTrackingScopeKind, - pub(crate) scope_id: String, - pub(crate) day_key: i64, - pub(crate) user_id: Option, - pub(crate) owner_user_id: Option, - pub(crate) profile_id: Option, - pub(crate) module_key: Option, - pub(crate) metadata_json: String, - pub(crate) occurred_at: Timestamp, -} - -#[spacetimedb::table( - accessor = tracking_daily_stat, - index( - accessor = by_tracking_daily_stat_event_day, - btree(columns = [event_key, day_key]) - ), - index( - accessor = by_tracking_daily_stat_scope_day, - btree(columns = [scope_kind, scope_id, day_key]) - ) -)] -pub struct TrackingDailyStat { - #[primary_key] - pub(crate) stat_id: String, - pub(crate) event_key: String, - pub(crate) scope_kind: RuntimeTrackingScopeKind, - pub(crate) scope_id: String, - pub(crate) day_key: i64, - pub(crate) count: u32, - pub(crate) first_occurred_at: Timestamp, - pub(crate) last_occurred_at: Timestamp, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table(accessor = profile_task_config)] -pub struct ProfileTaskConfig { - #[primary_key] - pub(crate) task_id: String, - pub(crate) title: String, - pub(crate) description: String, - pub(crate) event_key: String, - pub(crate) cycle: RuntimeProfileTaskCycle, - pub(crate) scope_kind: RuntimeTrackingScopeKind, - pub(crate) threshold: u32, - pub(crate) reward_points: u64, - pub(crate) enabled: bool, - pub(crate) sort_order: i32, - pub(crate) created_by: String, - pub(crate) created_at: Timestamp, - pub(crate) updated_by: String, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_task_progress, - index(accessor = by_profile_task_progress_user, btree(columns = [user_id])), - index( - accessor = by_profile_task_progress_user_task, - btree(columns = [user_id, task_id]) - ) -)] -pub struct ProfileTaskProgress { - #[primary_key] - pub(crate) progress_id: String, - pub(crate) user_id: String, - pub(crate) task_id: String, - pub(crate) day_key: i64, - pub(crate) progress_count: u32, - pub(crate) threshold: u32, - pub(crate) status: RuntimeProfileTaskStatus, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_task_reward_claim, - index(accessor = by_profile_task_claim_user, btree(columns = [user_id])), - index( - accessor = by_profile_task_claim_user_task, - btree(columns = [user_id, task_id]) - ) -)] -pub struct ProfileTaskRewardClaim { - #[primary_key] - pub(crate) claim_id: String, - pub(crate) user_id: String, - pub(crate) task_id: String, - pub(crate) day_key: i64, - pub(crate) reward_points: u64, - pub(crate) wallet_ledger_id: String, - pub(crate) claimed_at: Timestamp, -} - -#[spacetimedb::table(accessor = profile_redeem_code)] -pub struct ProfileRedeemCode { - #[primary_key] - pub(crate) code: String, - pub(crate) mode: RuntimeProfileRedeemCodeMode, - pub(crate) reward_points: u64, - pub(crate) max_uses: u32, - pub(crate) global_used_count: u32, - pub(crate) enabled: bool, - pub(crate) allowed_user_ids: Vec, - pub(crate) created_by: String, - pub(crate) created_at: Timestamp, - pub(crate) updated_at: Timestamp, - #[default(None::)] - pub(crate) starts_at: Option, - #[default(None::)] - pub(crate) expires_at: Option, -} - -#[spacetimedb::table( - accessor = profile_redeem_code_usage, - index(accessor = by_profile_redeem_code_usage_code, btree(columns = [code])), - index(accessor = by_profile_redeem_code_usage_user_id, btree(columns = [user_id])), - index( - accessor = by_profile_redeem_code_usage_code_user_id, - btree(columns = [code, user_id]) - ) -)] -pub struct ProfileRedeemCodeUsage { - #[primary_key] - pub(crate) usage_id: String, - pub(crate) code: String, - pub(crate) user_id: String, - pub(crate) amount_granted: u64, - pub(crate) created_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_code_operation, - index( - accessor = by_profile_code_operation_code_kind, - btree(columns = [code_kind]) - ), - index( - accessor = by_profile_code_operation_kind_code, - btree(columns = [code_kind, code]) - ) -)] -pub struct ProfileCodeOperation { - #[primary_key] - pub(crate) operation_id: String, - pub(crate) code_kind: String, - pub(crate) code: String, - pub(crate) action: String, - pub(crate) operator_user_id: String, - pub(crate) created_at: Timestamp, -} - -#[spacetimedb::table(accessor = profile_invite_code)] -pub struct ProfileInviteCode { - #[primary_key] - pub(crate) user_id: String, - #[unique] - pub(crate) invite_code: String, - pub(crate) metadata_json: String, - pub(crate) created_at: Timestamp, - pub(crate) updated_at: Timestamp, - #[default(None::)] - pub(crate) starts_at: Option, - #[default(None::)] - pub(crate) expires_at: Option, -} - -#[spacetimedb::table( - accessor = profile_referral_relation, - index(accessor = by_profile_referral_inviter_user_id, btree(columns = [inviter_user_id])), - index( - accessor = by_profile_referral_inviter_bound_at, - btree(columns = [inviter_user_id, bound_at]) - ) -)] -pub struct ProfileReferralRelation { - #[primary_key] - pub(crate) invitee_user_id: String, - pub(crate) inviter_user_id: String, - pub(crate) invite_code: String, - pub(crate) inviter_reward_granted: bool, - pub(crate) invitee_reward_granted: bool, - pub(crate) bound_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_played_world, - index(accessor = by_profile_played_world_user_id, btree(columns = [user_id])), - index( - accessor = by_profile_played_world_user_world_key, - btree(columns = [user_id, world_key]) - ), - index( - accessor = by_profile_played_world_user_last_played_at, - btree(columns = [user_id, last_played_at]) - ) -)] -pub struct ProfilePlayedWorld { - #[primary_key] - pub(crate) played_world_id: String, - pub(crate) user_id: String, - pub(crate) world_key: String, - pub(crate) owner_user_id: Option, - pub(crate) profile_id: Option, - pub(crate) world_type: Option, - pub(crate) world_title: String, - pub(crate) world_subtitle: String, - pub(crate) first_played_at: Timestamp, - pub(crate) last_played_at: Timestamp, - pub(crate) last_observed_play_time_ms: u64, -} - -#[spacetimedb::table( - accessor = public_work_play_daily_stat, - index( - accessor = by_public_work_play_daily_stat_work_day, - btree(columns = [source_type, profile_id, played_day]) - ) -)] -pub struct PublicWorkPlayDailyStat { - #[primary_key] - pub(crate) stat_id: String, - // 中文注释:source_type 区分 custom-world / puzzle / big-fish,避免不同玩法 profile_id 撞桶。 - pub(crate) source_type: String, - pub(crate) owner_user_id: String, - pub(crate) profile_id: String, - // 中文注释:UTC 自 Unix 纪元起的自然日桶,用于快速聚合近 7 日新增游玩次数。 - pub(crate) played_day: i64, - pub(crate) play_count: u32, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = public_work_like, - index(accessor = by_public_work_like_work, btree(columns = [source_type, profile_id])), - index(accessor = by_public_work_like_user, btree(columns = [user_id])) -)] -pub struct PublicWorkLike { - #[primary_key] - pub(crate) like_id: String, - // 中文注释:source_type 与 play 统计保持同一套作品类型命名,确保跨玩法 profile_id 不会互相冲突。 - pub(crate) source_type: String, - pub(crate) owner_user_id: String, - pub(crate) profile_id: String, - pub(crate) user_id: String, - pub(crate) liked_at: Timestamp, -} - -pub(crate) struct ProfilePlayedWorkUpsertInput { - pub(crate) user_id: String, - pub(crate) world_key: String, - pub(crate) owner_user_id: Option, - pub(crate) profile_id: Option, - pub(crate) world_type: Option, - pub(crate) world_title: String, - pub(crate) world_subtitle: String, - pub(crate) played_at_micros: i64, -} - -pub(crate) struct PublicWorkPlayRecordInput { - pub(crate) source_type: String, - pub(crate) owner_user_id: String, - pub(crate) profile_id: String, - pub(crate) played_at_micros: i64, -} - -pub(crate) struct PublicWorkLikeRecordInput { - pub(crate) source_type: String, - pub(crate) owner_user_id: String, - pub(crate) profile_id: String, - pub(crate) user_id: String, - pub(crate) liked_at_micros: i64, -} - -pub(crate) struct ProfileSaveArchiveUpsertInput { - pub(crate) user_id: String, - pub(crate) world_key: String, - pub(crate) owner_user_id: Option, - pub(crate) profile_id: Option, - pub(crate) world_type: Option, - pub(crate) world_name: String, - pub(crate) subtitle: String, - pub(crate) summary_text: String, - pub(crate) cover_image_src: Option, - pub(crate) bottom_tab: String, - pub(crate) game_state_json: String, - pub(crate) current_story_json: Option, - pub(crate) saved_at_micros: i64, -} - -#[spacetimedb::table(accessor = profile_membership)] -pub struct ProfileMembership { - #[primary_key] - pub(crate) user_id: String, - pub(crate) status: RuntimeProfileMembershipStatus, - pub(crate) tier: RuntimeProfileMembershipTier, - pub(crate) started_at: Timestamp, - pub(crate) expires_at: Timestamp, - pub(crate) updated_at: Timestamp, - #[default(None::)] - pub(crate) cycle_started_at: Option, - #[default(None::)] - pub(crate) cycle_resets_at: Option, - #[default(0u64)] - pub(crate) cycle_granted_points: u64, - #[default(0u64)] - pub(crate) cycle_remaining_points: u64, - #[default(30u32)] - pub(crate) cycle_period_days: u32, -} - -#[spacetimedb::table(accessor = profile_recharge_product_config)] -#[derive(Clone)] -pub struct ProfileRechargeProductConfig { - #[primary_key] - pub(crate) product_id: String, - pub(crate) title: String, - pub(crate) price_cents: u64, - pub(crate) kind: RuntimeProfileRechargeProductKind, - pub(crate) points_amount: u64, - pub(crate) bonus_points: u64, - pub(crate) duration_days: u32, - pub(crate) badge_label: String, - pub(crate) description: String, - pub(crate) tier: RuntimeProfileMembershipTier, - pub(crate) enabled: bool, - pub(crate) sort_order: i32, - pub(crate) created_by: String, - pub(crate) created_at: Timestamp, - pub(crate) updated_by: String, - pub(crate) updated_at: Timestamp, - #[default(0u64)] - pub(crate) membership_period_points: u64, - #[default(0u32)] - pub(crate) membership_period_days: u32, - #[default(0u32)] - pub(crate) membership_queue_limit: u32, - #[default(0u32)] - pub(crate) membership_discount_bps: u32, -} - -#[spacetimedb::table( - accessor = profile_recharge_order, - index(accessor = by_profile_recharge_order_user_id, btree(columns = [user_id])), - index( - accessor = by_profile_recharge_order_user_created_at, - btree(columns = [user_id, created_at]) - ) -)] -#[derive(Clone)] -pub struct ProfileRechargeOrder { - #[primary_key] - pub(crate) order_id: String, - pub(crate) user_id: String, - pub(crate) product_id: String, - pub(crate) product_title: String, - pub(crate) kind: RuntimeProfileRechargeProductKind, - pub(crate) amount_cents: u64, - pub(crate) status: RuntimeProfileRechargeOrderStatus, - pub(crate) payment_channel: String, - #[default(None::)] - pub(crate) paid_at: Option, - #[default(None::)] - pub(crate) provider_transaction_id: Option, - pub(crate) created_at: Timestamp, - pub(crate) points_delta: i64, - pub(crate) membership_expires_at: Option, - #[default(None::)] - pub(crate) expired_at: Option, - #[default(None::)] - pub(crate) expiration_checked_at: Option, - #[default(None::)] - pub(crate) expiration_provider_state: Option, - #[default(None::)] - pub(crate) expiration_last_error: Option, -} - -#[spacetimedb::table( - accessor = profile_recharge_refund, - index(accessor = by_profile_recharge_refund_order_id, btree(columns = [order_id])), - index( - accessor = by_profile_recharge_refund_status_updated_at, - btree(columns = [provider_status, updated_at]) - ) -)] -#[derive(Clone)] -pub struct ProfileRechargeRefund { - #[primary_key] - pub(crate) out_refund_no: String, - #[unique] - pub(crate) provider_refund_id: String, - pub(crate) order_id: String, - pub(crate) provider_transaction_id: String, - pub(crate) user_id: Option, - pub(crate) provider_status: RuntimeProfileRechargeRefundStatus, - pub(crate) total_cents: u64, - pub(crate) refund_cents: u64, - pub(crate) payer_total_cents: u64, - pub(crate) payer_refund_cents: u64, - pub(crate) success_at: Option, - pub(crate) first_observed_at: Timestamp, - pub(crate) updated_at: Timestamp, - pub(crate) last_observation_source: RuntimeProfileRechargeRefundObservationSource, - pub(crate) last_observation_id: String, - pub(crate) order_settled_at: Option, - pub(crate) target_recovery_points: u64, - pub(crate) recovered_points: u64, - pub(crate) unrecovered_points: u64, - pub(crate) recovery_status: RuntimeProfileRechargeRefundRecoveryStatus, - pub(crate) last_recovery_ledger_id: Option, - pub(crate) last_error_code: Option, - #[default(None::)] - pub(crate) manual_review_resolved_by_admin_user_id: Option, - #[default(None::)] - pub(crate) manual_review_resolution_reason: Option, - #[default(None::)] - pub(crate) manual_review_resolved_at: Option, - #[default(None::)] - pub(crate) manual_review_resolved_error_code: Option, -} - -#[spacetimedb::table( - accessor = profile_recharge_refund_observation, - index( - accessor = by_profile_recharge_refund_observation_refund, - btree(columns = [out_refund_no, observed_at]) - ) -)] -#[derive(Clone)] -pub struct ProfileRechargeRefundObservation { - #[primary_key] - pub(crate) observation_id: String, - pub(crate) out_refund_no: String, - pub(crate) provider_refund_id: String, - pub(crate) order_id: String, - pub(crate) provider_transaction_id: String, - pub(crate) source: RuntimeProfileRechargeRefundObservationSource, - pub(crate) provider_status: RuntimeProfileRechargeRefundStatus, - pub(crate) total_cents: u64, - pub(crate) refund_cents: u64, - pub(crate) payer_total_cents: u64, - pub(crate) payer_refund_cents: u64, - pub(crate) success_at: Option, - pub(crate) notification_ref: Option, - pub(crate) payload_fingerprint: String, - pub(crate) resolution_code: String, - pub(crate) observed_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_recharge_order_refund_settlement, - index( - accessor = by_profile_recharge_order_refund_settlement_user_id, - btree(columns = [user_id]) - ) -)] -#[derive(Clone)] -pub struct ProfileRechargeOrderRefundSettlement { - #[primary_key] - pub(crate) order_id: String, - pub(crate) user_id: String, - pub(crate) successful_refund_count: u32, - pub(crate) cumulative_success_refund_cents: u64, - pub(crate) target_recovery_points: u64, - pub(crate) recovered_points: u64, - pub(crate) unrecovered_points: u64, - pub(crate) recovery_status: RuntimeProfileRechargeRefundRecoveryStatus, - pub(crate) wallet_frozen: bool, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_recharge_refund_hold, - index(accessor = by_profile_recharge_refund_hold_order_id, btree(columns = [order_id])), - index(accessor = by_profile_recharge_refund_hold_user_id, btree(columns = [user_id])), - index(accessor = by_profile_recharge_refund_hold_status, btree(columns = [status])) -)] -#[derive(Clone)] -pub struct ProfileRechargeRefundHold { - #[primary_key] - pub(crate) out_refund_no: String, - pub(crate) order_id: String, - pub(crate) user_id: String, - pub(crate) refund_cents: u64, - pub(crate) held_points: u64, - pub(crate) status: RuntimeProfileRechargeRefundHoldStatus, - pub(crate) admin_user_id: String, - pub(crate) reason: String, - pub(crate) created_at: Timestamp, - pub(crate) updated_at: Timestamp, - pub(crate) settled_at: Option, - pub(crate) released_at: Option, - pub(crate) released_by_admin_user_id: Option, - pub(crate) release_reason: Option, -} - -#[spacetimedb::table(accessor = profile_wallet_manual_restriction)] -#[derive(Clone)] -pub struct ProfileWalletManualRestriction { - #[primary_key] - pub(crate) user_id: String, - pub(crate) frozen: bool, - pub(crate) reason: String, - pub(crate) created_by_admin_user_id: String, - pub(crate) created_at: Timestamp, - pub(crate) updated_by_admin_user_id: String, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table(accessor = profile_recharge_refund_bill_checkpoint)] -#[derive(Clone)] -pub struct ProfileRechargeRefundBillCheckpoint { - #[primary_key] - pub(crate) checkpoint_id: String, - pub(crate) bill_date: String, - pub(crate) bill_hash: String, - pub(crate) processed_refund_count: u32, - pub(crate) completed_at: Timestamp, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_recharge_order_expiration_schedule, - index( - accessor = by_profile_recharge_order_expiration_scheduled_at, - btree(columns = [scheduled_at]) - ) -)] -#[derive(Clone)] -pub struct ProfileRechargeOrderExpirationSchedule { - #[primary_key] - pub(crate) order_id: String, - pub(crate) user_id: String, - pub(crate) scheduled_at: Timestamp, - #[default(None::)] - pub(crate) lease_owner: Option, - #[default(None::)] - pub(crate) lease_expires_at: Option, - pub(crate) created_at: Timestamp, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_recharge_order_expiration_timer, - scheduled(expire_profile_recharge_order_timer) -)] -#[derive(Clone)] -pub struct ProfileRechargeOrderExpirationTimer { - #[primary_key] - #[auto_inc] - pub(crate) scheduled_id: u64, - #[unique] - pub(crate) order_id: String, - pub(crate) user_id: String, - pub(crate) scheduled_at: ScheduleAt, - pub(crate) created_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_feedback_submission, - index(accessor = by_profile_feedback_user_id, btree(columns = [user_id])), - index( - accessor = by_profile_feedback_user_created_at, - btree(columns = [user_id, created_at]) - ) -)] -pub struct ProfileFeedbackSubmission { - #[primary_key] - pub(crate) feedback_id: String, - pub(crate) user_id: String, - pub(crate) description: String, - pub(crate) contact_phone: Option, - // 中文注释:首版凭证以 Data URL 写入私有表,HTTP 回包只返回元数据,后续迁 OSS 不改变外部契约。 - pub(crate) evidence_json: String, - pub(crate) status: RuntimeProfileFeedbackStatus, - pub(crate) created_at: Timestamp, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_save_archive, - index(accessor = by_profile_save_archive_user_id, btree(columns = [user_id])), - index( - accessor = by_profile_save_archive_user_world_key, - btree(columns = [user_id, world_key]) - ), - index( - accessor = by_profile_save_archive_user_saved_at, - btree(columns = [user_id, saved_at]) - ) -)] -pub struct ProfileSaveArchive { - #[primary_key] - pub(crate) archive_id: String, - pub(crate) user_id: String, - pub(crate) world_key: String, - pub(crate) owner_user_id: Option, - pub(crate) profile_id: Option, - pub(crate) world_type: Option, - pub(crate) world_name: String, - pub(crate) subtitle: String, - pub(crate) summary_text: String, - pub(crate) cover_image_src: Option, - pub(crate) saved_at: Timestamp, - pub(crate) bottom_tab: String, - pub(crate) game_state_json: String, - pub(crate) current_story_json: Option, - pub(crate) created_at: Timestamp, - pub(crate) updated_at: Timestamp, -} - -// save archive 列表是按世界聚合后的最近一次快照视图,读取时只做排序,不再拼装默认值。 -#[spacetimedb::procedure] -pub fn list_profile_save_archives( - ctx: &mut ProcedureContext, - input: RuntimeProfileSaveArchiveListInput, -) -> RuntimeProfileSaveArchiveProcedureResult { - match ctx.try_with_tx(|tx| list_profile_save_archive_rows(tx, input.clone())) { - Ok(entries) => RuntimeProfileSaveArchiveProcedureResult { - ok: true, - entries, - record: None, - current_snapshot: None, - error_message: None, - }, - Err(message) => RuntimeProfileSaveArchiveProcedureResult { - ok: false, - entries: Vec::new(), - record: None, - current_snapshot: None, - error_message: Some(message), - }, - } -} - -// resume 会把指定 archive 回填到当前 snapshot,并同步返回 entry + 当前 snapshot。 -#[spacetimedb::procedure] -pub fn resume_profile_save_archive_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileSaveArchiveResumeInput, -) -> RuntimeProfileSaveArchiveProcedureResult { - match ctx.try_with_tx(|tx| resume_profile_save_archive_record(tx, input.clone())) { - Ok((record, current_snapshot)) => RuntimeProfileSaveArchiveProcedureResult { - ok: true, - entries: Vec::new(), - record: Some(record), - current_snapshot: Some(current_snapshot), - error_message: None, - }, - Err(message) => RuntimeProfileSaveArchiveProcedureResult { - ok: false, - entries: Vec::new(), - record: None, - current_snapshot: None, - error_message: Some(message), - }, - } -} - -// profile dashboard 当前先作为 projection 读入口返回默认零值,等待 runtime_snapshot 写链补齐刷新。 -#[spacetimedb::procedure] -pub fn get_profile_dashboard( - ctx: &mut ProcedureContext, - input: RuntimeProfileDashboardGetInput, -) -> RuntimeProfileDashboardProcedureResult { - match ctx.try_with_tx(|tx| get_profile_dashboard_snapshot(tx, input.clone())) { - Ok(record) => RuntimeProfileDashboardProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileDashboardProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 钱包流水当前只暴露最近 50 条只读视图,排序与截断逻辑在 procedure 内统一收口。 -#[spacetimedb::procedure] -pub fn list_profile_wallet_ledger( - ctx: &mut ProcedureContext, - input: RuntimeProfileWalletLedgerListInput, -) -> RuntimeProfileWalletLedgerProcedureResult { - match ctx.try_with_tx(|tx| list_profile_wallet_ledger_entries(tx, input.clone())) { - Ok(entries) => RuntimeProfileWalletLedgerProcedureResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => RuntimeProfileWalletLedgerProcedureResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -// analytics metric 查询直接聚合 tracking_daily_stat,避免 API 层订阅全量表后自行汇总。 -#[spacetimedb::procedure] -pub fn query_analytics_metric( - ctx: &mut ProcedureContext, - input: AnalyticsMetricQueryInput, -) -> AnalyticsMetricQueryProcedureResult { - match ctx.try_with_tx(|tx| query_analytics_metric_buckets(tx, input.clone())) { - Ok(buckets) => AnalyticsMetricQueryProcedureResult { - ok: true, - buckets, - error_message: None, - }, - Err(message) => AnalyticsMetricQueryProcedureResult { - ok: false, - buckets: Vec::new(), - error_message: Some(message), - }, - } -} - -// 通用埋点入口开放给 Axum 调用;具体入口仍在业务 handler 成功后显式触发。 -#[spacetimedb::procedure] -pub fn record_tracking_event_and_return( - ctx: &mut ProcedureContext, - input: RuntimeTrackingEventInput, -) -> RuntimeTrackingEventProcedureResult { - match ctx.try_with_tx(|tx| record_tracking_event(tx, input.clone())) { - Ok(()) => RuntimeTrackingEventProcedureResult { - ok: true, - error_message: None, - }, - Err(message) => RuntimeTrackingEventProcedureResult { - ok: false, - error_message: Some(message), - }, - } -} - -// 高频 route tracking 由 api-server 本机 outbox 批量写入,减少公开列表热路径上的 procedure 调用次数。 -#[spacetimedb::procedure] -pub fn record_tracking_events_and_return( - ctx: &mut ProcedureContext, - inputs: Vec, -) -> RuntimeTrackingEventBatchProcedureResult { - match ctx.try_with_tx(|tx| { - let mut accepted_count = 0u32; - for input in &inputs { - record_tracking_event(tx, input.clone())?; - accepted_count = accepted_count.saturating_add(1); - } - Ok(accepted_count) - }) { - Ok(accepted_count) => RuntimeTrackingEventBatchProcedureResult { - ok: true, - accepted_count, - error_message: None, - }, - Err(message) => RuntimeTrackingEventBatchProcedureResult { - ok: false, - accepted_count: 0, - error_message: Some(message), - }, - } -} - -// 登录成功埋点由认证链路主动调用;任务中心只负责读取和刷新任务进度。 -#[spacetimedb::procedure] -pub fn record_daily_login_tracking_event_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileTaskCenterGetInput, -) -> RuntimeTrackingEventProcedureResult { - match ctx.try_with_tx(|tx| { - let validated_input = build_runtime_profile_task_center_get_input(input.user_id.clone()) - .map_err(|error| error.to_string())?; - ensure_default_profile_task_config(tx); - record_daily_login_tracking_event(tx, &validated_input.user_id) - }) { - Ok(()) => RuntimeTrackingEventProcedureResult { - ok: true, - error_message: None, - }, - Err(message) => RuntimeTrackingEventProcedureResult { - ok: false, - error_message: Some(message), - }, - } -} - -// 任务中心读取会刷新进度;每日登录埋点应由登录成功链路提前记录。 -#[spacetimedb::procedure] -pub fn get_profile_task_center( - ctx: &mut ProcedureContext, - input: RuntimeProfileTaskCenterGetInput, -) -> RuntimeProfileTaskCenterProcedureResult { - match ctx.try_with_tx(|tx| get_profile_task_center_snapshot(tx, input.clone(), false)) { - Ok(record) => RuntimeProfileTaskCenterProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileTaskCenterProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 领奖记录与泥点流水在同一事务内写入,避免任务状态和钱包余额漂移。 -#[spacetimedb::procedure] -pub fn claim_profile_task_reward_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileTaskClaimInput, -) -> RuntimeProfileTaskClaimProcedureResult { - match ctx.try_with_tx(|tx| claim_profile_task_reward_record(tx, input.clone())) { - Ok(record) => RuntimeProfileTaskClaimProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileTaskClaimProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_list_profile_task_configs( - ctx: &mut ProcedureContext, - input: RuntimeProfileTaskConfigAdminListInput, -) -> RuntimeProfileTaskConfigAdminListProcedureResult { - match ctx.try_with_tx(|tx| list_profile_task_config_snapshots(tx, input.clone())) { - Ok(entries) => RuntimeProfileTaskConfigAdminListProcedureResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => RuntimeProfileTaskConfigAdminListProcedureResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_upsert_profile_task_config( - ctx: &mut ProcedureContext, - input: RuntimeProfileTaskConfigAdminUpsertInput, -) -> RuntimeProfileTaskConfigAdminProcedureResult { - match ctx.try_with_tx(|tx| upsert_profile_task_config_record(tx, input.clone())) { - Ok(record) => RuntimeProfileTaskConfigAdminProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileTaskConfigAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_disable_profile_task_config( - ctx: &mut ProcedureContext, - input: RuntimeProfileTaskConfigAdminDisableInput, -) -> RuntimeProfileTaskConfigAdminProcedureResult { - match ctx.try_with_tx(|tx| disable_profile_task_config_record(tx, input.clone())) { - Ok(record) => RuntimeProfileTaskConfigAdminProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileTaskConfigAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_get_profile_wallet_config( - ctx: &mut ProcedureContext, - input: RuntimeProfileWalletConfigAdminGetInput, -) -> RuntimeProfileWalletConfigAdminProcedureResult { - match ctx.try_with_tx(|tx| get_profile_wallet_config_snapshot(tx, input.clone())) { - Ok(record) => RuntimeProfileWalletConfigAdminProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileWalletConfigAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_upsert_profile_wallet_config( - ctx: &mut ProcedureContext, - input: RuntimeProfileWalletConfigAdminUpsertInput, -) -> RuntimeProfileWalletConfigAdminProcedureResult { - match ctx.try_with_tx(|tx| upsert_profile_wallet_config_record(tx, input.clone())) { - Ok(record) => RuntimeProfileWalletConfigAdminProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileWalletConfigAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_list_profile_recharge_products( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeProductAdminListInput, -) -> RuntimeProfileRechargeProductAdminListProcedureResult { - match ctx.try_with_tx(|tx| list_profile_recharge_product_config_snapshots(tx, input.clone())) { - Ok(entries) => RuntimeProfileRechargeProductAdminListProcedureResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => RuntimeProfileRechargeProductAdminListProcedureResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_upsert_profile_recharge_product( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeProductAdminUpsertInput, -) -> RuntimeProfileRechargeProductAdminProcedureResult { - match ctx.try_with_tx(|tx| upsert_profile_recharge_product_config_record(tx, input.clone())) { - Ok(record) => RuntimeProfileRechargeProductAdminProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileRechargeProductAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 新用户注册赠送由后端注册链路调用;流水 ID 固定,保证重试不重复发放。 -#[spacetimedb::procedure] -pub fn grant_new_user_registration_wallet_reward( - ctx: &mut ProcedureContext, - input: RuntimeProfileDashboardGetInput, -) -> RuntimeProfileWalletAdjustmentProcedureResult { - match ctx.try_with_tx(|tx| grant_new_user_registration_wallet_reward_tx(tx, input.clone())) { - Ok(record) => RuntimeProfileWalletAdjustmentProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileWalletAdjustmentProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 资产生成由 Axum 调用外部模型,钱包扣费必须先在 SpacetimeDB 内原子落账。 -#[spacetimedb::procedure] -pub fn consume_profile_wallet_points_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileWalletAdjustmentInput, -) -> RuntimeProfileWalletAdjustmentProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - apply_profile_wallet_adjustment( - tx, - input.clone(), - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, - true, - ) - }) { - Ok(record) => RuntimeProfileWalletAdjustmentProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileWalletAdjustmentProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 生成链路失败时由 Axum 调用退款,ledger_id 幂等保证重复补偿不会重复加钱。 -#[spacetimedb::procedure] -pub fn refund_profile_wallet_points_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileWalletAdjustmentInput, -) -> RuntimeProfileWalletAdjustmentProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - apply_profile_wallet_adjustment( - tx, - input.clone(), - RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, - false, - ) - }) { - Ok(record) => RuntimeProfileWalletAdjustmentProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileWalletAdjustmentProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// play stats 与 dashboard 共用 dashboard projection 的 total_play_time / updated_at,避免 Axum 侧拼装。 -#[spacetimedb::procedure] -pub fn get_profile_play_stats( - ctx: &mut ProcedureContext, - input: RuntimeProfilePlayStatsGetInput, -) -> RuntimeProfilePlayStatsProcedureResult { - match ctx.try_with_tx(|tx| get_profile_play_stats_snapshot(tx, input.clone())) { - Ok(record) => RuntimeProfilePlayStatsProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfilePlayStatsProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 账户充值中心只读快照,套餐和权益由后端返回,前端不保存业务价格表。 -#[spacetimedb::procedure] -pub fn get_profile_recharge_center( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeCenterGetInput, -) -> RuntimeProfileRechargeCenterProcedureResult { - match ctx.try_with_tx(|tx| get_profile_recharge_center_snapshot(tx, input.clone())) { - Ok(record) => RuntimeProfileRechargeCenterProcedureResult { - ok: true, - record: Some(record), - order: None, - error_message: None, - }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn get_profile_recharge_order_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeOrderGetInput, -) -> RuntimeProfileRechargeCenterProcedureResult { - match ctx.try_with_tx(|tx| get_profile_recharge_order_snapshot(tx, input.clone())) { - Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult { - ok: true, - record: Some(record), - order: Some(order), - error_message: None, - }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn create_profile_recharge_order_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeOrderCreateInput, -) -> RuntimeProfileRechargeCenterProcedureResult { - match ctx.try_with_tx(|tx| create_profile_recharge_order_record(tx, input.clone())) { - Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult { - ok: true, - record: Some(record), - order: Some(order), - error_message: None, - }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn mark_profile_recharge_order_paid_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeOrderPaidInput, -) -> RuntimeProfileRechargeCenterProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - mark_profile_recharge_order_paid_record(tx, input.clone()) - }) { - Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult { - ok: true, - record: Some(record), - order: Some(order), - error_message: None, - }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn record_profile_recharge_refund_observation_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeRefundObservationInput, -) -> RuntimeProfileRechargeRefundProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - record_profile_recharge_refund_observation(tx, input.clone()) - }) { - Ok((record, settlement, duplicate, resolution_code)) => { - RuntimeProfileRechargeRefundProcedureResult { - ok: true, - record: Some(record), - settlement, - duplicate, - resolution_code, - error_message: None, - } - } - Err(message) => RuntimeProfileRechargeRefundProcedureResult { - ok: false, - record: None, - settlement: None, - duplicate: false, - resolution_code: "failed".to_string(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn get_profile_recharge_refund_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeRefundGetInput, -) -> RuntimeProfileRechargeRefundProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - let validated = - build_runtime_profile_recharge_refund_get_input(input.out_refund_no.clone())?; - let row = tx - .db - .profile_recharge_refund() - .out_refund_no() - .find(&validated.out_refund_no) - .ok_or_else(|| "profile_recharge_refund 不存在".to_string())?; - let settlement = tx - .db - .profile_recharge_order_refund_settlement() - .order_id() - .find(&row.order_id) - .map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(&value)); - Ok(( - build_profile_recharge_refund_snapshot_from_row(&row), - settlement, - )) - }) { - Ok((record, settlement)) => RuntimeProfileRechargeRefundProcedureResult { - ok: true, - record: Some(record), - settlement, - duplicate: false, - resolution_code: "loaded".to_string(), - error_message: None, - }, - Err(message) => RuntimeProfileRechargeRefundProcedureResult { - ok: false, - record: None, - settlement: None, - duplicate: false, - resolution_code: "failed".to_string(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn list_profile_recharge_refunds_for_reconciliation( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeRefundReconciliationListInput, -) -> RuntimeProfileRechargeRefundListProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - Ok(list_profile_recharge_refund_reconciliation_rows( - tx, - input.clone(), - )) - }) { - Ok(entries) => RuntimeProfileRechargeRefundListProcedureResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => RuntimeProfileRechargeRefundListProcedureResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_list_profile_recharge_orders_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeOrderAdminListInput, -) -> RuntimeProfileRechargeOrderAdminListProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - admin_list_profile_recharge_order_entries(tx, input.clone()) - }) { - Ok(entries) => RuntimeProfileRechargeOrderAdminListProcedureResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => RuntimeProfileRechargeOrderAdminListProcedureResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn preview_profile_recharge_refund_hold_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeRefundHoldPreviewInput, -) -> RuntimeProfileRechargeRefundHoldProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - preview_profile_recharge_refund_hold(tx, input.clone()) - }) { - Ok((record, order, settlement, wallet)) => { - RuntimeProfileRechargeRefundHoldProcedureResult { - ok: true, - record: Some(record), - order: Some(order), - settlement, - wallet: Some(wallet), - error_message: None, - } - } - Err(message) => RuntimeProfileRechargeRefundHoldProcedureResult { - ok: false, - record: None, - order: None, - settlement: None, - wallet: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn prepare_profile_recharge_refund_hold_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeRefundHoldPrepareInput, -) -> RuntimeProfileRechargeRefundHoldProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - prepare_profile_recharge_refund_hold(tx, input.clone()) - }) { - Ok((record, order, settlement, wallet)) => { - RuntimeProfileRechargeRefundHoldProcedureResult { - ok: true, - record: Some(record), - order: Some(order), - settlement, - wallet: Some(wallet), - error_message: None, - } - } - Err(message) => RuntimeProfileRechargeRefundHoldProcedureResult { - ok: false, - record: None, - order: None, - settlement: None, - wallet: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn release_profile_recharge_refund_hold_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeRefundHoldReleaseInput, -) -> RuntimeProfileRechargeRefundHoldProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - release_profile_recharge_refund_hold(tx, input.clone()) - }) { - Ok((record, order, settlement, wallet)) => { - RuntimeProfileRechargeRefundHoldProcedureResult { - ok: true, - record: Some(record), - order: Some(order), - settlement, - wallet: Some(wallet), - error_message: None, - } - } - Err(message) => RuntimeProfileRechargeRefundHoldProcedureResult { - ok: false, - record: None, - order: None, - settlement: None, - wallet: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn resolve_profile_recharge_refund_manual_review_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeRefundManualReviewResolveInput, -) -> RuntimeProfileRechargeRefundProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - resolve_profile_recharge_refund_manual_review(tx, input.clone()) - }) { - Ok((record, settlement, duplicate, resolution_code)) => { - RuntimeProfileRechargeRefundProcedureResult { - ok: true, - record: Some(record), - settlement, - duplicate, - resolution_code, - error_message: None, - } - } - Err(message) => RuntimeProfileRechargeRefundProcedureResult { - ok: false, - record: None, - settlement: None, - duplicate: false, - resolution_code: "manual_review_resolution_failed".to_string(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn list_profile_recharge_refund_holds_for_reconciliation( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeRefundHoldListInput, -) -> RuntimeProfileRechargeRefundHoldListProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - let validated = build_runtime_profile_recharge_refund_hold_list_input(input.limit); - let mut rows = tx - .db - .profile_recharge_refund_hold() - .by_profile_recharge_refund_hold_status() - .filter(RuntimeProfileRechargeRefundHoldStatus::Active) - .collect::>(); - rows.sort_by(|left, right| { - left.updated_at - .to_micros_since_unix_epoch() - .cmp(&right.updated_at.to_micros_since_unix_epoch()) - .then_with(|| left.out_refund_no.cmp(&right.out_refund_no)) - }); - let rotation_slot = tx - .timestamp - .to_micros_since_unix_epoch() - .div_euclid(60 * 1_000_000) - .unsigned_abs(); - Ok(select_profile_recharge_refund_hold_reconciliation_page( - rows, - validated.limit as usize, - rotation_slot, - ) - .into_iter() - .map(|row| build_profile_recharge_refund_hold_snapshot_from_row(&row)) - .collect()) - }) { - Ok(entries) => RuntimeProfileRechargeRefundHoldListProcedureResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => RuntimeProfileRechargeRefundHoldListProcedureResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_get_profile_wallet_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileAdminWalletGetInput, -) -> RuntimeProfileAdminWalletProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - let validated = build_runtime_profile_admin_wallet_get_input(input.user_id.clone())?; - Ok(build_profile_admin_wallet_snapshot(tx, &validated.user_id)) - }) { - Ok(record) => RuntimeProfileAdminWalletProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileAdminWalletProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_upsert_profile_wallet_manual_restriction_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileWalletManualRestrictionUpsertInput, -) -> RuntimeProfileAdminWalletProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - let validated = build_runtime_profile_wallet_manual_restriction_upsert_input( - input.user_id.clone(), - input.frozen, - input.reason.clone(), - input.admin_user_id.clone(), - )?; - upsert_profile_wallet_manual_restriction(tx, validated); - Ok(build_profile_admin_wallet_snapshot(tx, &input.user_id)) - }) { - Ok(record) => RuntimeProfileAdminWalletProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileAdminWalletProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn get_profile_recharge_refund_bill_checkpoint_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeRefundBillCheckpointGetInput, -) -> RuntimeProfileRechargeRefundBillCheckpointProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - let validated = build_runtime_profile_recharge_refund_bill_checkpoint_get_input( - input.checkpoint_id.clone(), - )?; - Ok(tx - .db - .profile_recharge_refund_bill_checkpoint() - .checkpoint_id() - .find(&validated.checkpoint_id) - .map(|row| build_profile_recharge_refund_bill_checkpoint_snapshot_from_row(&row))) - }) { - Ok(record) => RuntimeProfileRechargeRefundBillCheckpointProcedureResult { - ok: true, - record, - error_message: None, - }, - Err(message) => RuntimeProfileRechargeRefundBillCheckpointProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn advance_profile_recharge_refund_bill_checkpoint_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeRefundBillCheckpointAdvanceInput, -) -> RuntimeProfileRechargeRefundBillCheckpointProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - advance_profile_recharge_refund_bill_checkpoint(tx, input.clone()) - }) { - Ok(record) => RuntimeProfileRechargeRefundBillCheckpointProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileRechargeRefundBillCheckpointProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn claim_profile_recharge_order_expiration_schedule_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeOrderExpirationClaimInput, -) -> RuntimeProfileRechargeOrderExpirationClaimProcedureResult { - match ctx.try_with_tx(|tx| claim_profile_recharge_order_expiration_schedules(tx, input.clone())) - { - Ok(entries) => RuntimeProfileRechargeOrderExpirationClaimProcedureResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => RuntimeProfileRechargeOrderExpirationClaimProcedureResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn complete_profile_recharge_order_expiration_schedule_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeOrderExpirationCompleteInput, -) -> RuntimeProfileRechargeOrderExpirationCompleteProcedureResult { - match ctx - .try_with_tx(|tx| complete_profile_recharge_order_expiration_schedule(tx, input.clone())) - { - Ok(()) => RuntimeProfileRechargeOrderExpirationCompleteProcedureResult { - ok: true, - error_message: None, - }, - Err(message) => RuntimeProfileRechargeOrderExpirationCompleteProcedureResult { - ok: false, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn list_unchecked_expired_profile_recharge_orders( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeOrderExpirationCheckListInput, -) -> RuntimeProfileRechargeOrderExpirationCheckListProcedureResult { - match ctx - .try_with_tx(|tx| list_unchecked_expired_profile_recharge_order_rows(tx, input.clone())) - { - Ok(entries) => RuntimeProfileRechargeOrderExpirationCheckListProcedureResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => RuntimeProfileRechargeOrderExpirationCheckListProcedureResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn mark_profile_recharge_order_expiration_checked( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeOrderExpirationCheckInput, -) -> RuntimeProfileRechargeOrderExpirationCheckProcedureResult { - match ctx - .try_with_tx(|tx| mark_profile_recharge_order_expiration_checked_record(tx, input.clone())) - { - Ok(record) => RuntimeProfileRechargeOrderExpirationCheckProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileRechargeOrderExpirationCheckProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::reducer] -pub fn expire_profile_recharge_order_timer( - ctx: &ReducerContext, - timer: ProfileRechargeOrderExpirationTimer, -) -> Result<(), String> { - if !ctx.sender_auth().is_internal() { - return Err("profile_recharge_order_expiration_timer scheduler-only".to_string()); - } - - let Some(mut order) = ctx - .db - .profile_recharge_order() - .order_id() - .find(&timer.order_id) - else { - delete_profile_recharge_order_expiration_timer(ctx, &timer.order_id); - return Ok(()); - }; - - if order.status == RuntimeProfileRechargeOrderStatus::Pending { - ctx.db - .profile_recharge_order() - .order_id() - .delete(&order.order_id); - order.status = RuntimeProfileRechargeOrderStatus::Expired; - order.expired_at = Some(ctx.timestamp); - ctx.db.profile_recharge_order().insert(order); - } - delete_profile_recharge_order_expiration_timer(ctx, &timer.order_id); - Ok(()) -} - -#[spacetimedb::procedure] -pub fn close_profile_recharge_order_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeOrderCloseInput, -) -> RuntimeProfileRechargeCenterProcedureResult { - match ctx.try_with_tx(|tx| close_profile_recharge_order_record(tx, input.clone())) { - Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult { - ok: true, - record: Some(record), - order: Some(order), - error_message: None, - }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn submit_profile_feedback_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileFeedbackSubmissionInput, -) -> RuntimeProfileFeedbackSubmissionProcedureResult { - match ctx.try_with_tx(|tx| submit_profile_feedback_record(tx, input.clone())) { - Ok(record) => RuntimeProfileFeedbackSubmissionProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileFeedbackSubmissionProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 邀请中心会在首次打开时为账号创建稳定邀请码,前端只展示这里返回的后端状态。 -#[spacetimedb::procedure] -pub fn get_profile_referral_invite_center( - ctx: &mut ProcedureContext, - input: RuntimeReferralInviteCenterGetInput, -) -> RuntimeReferralInviteCenterProcedureResult { - match ctx.try_with_tx(|tx| get_profile_referral_invite_center_snapshot(tx, input.clone())) { - Ok(record) => RuntimeReferralInviteCenterProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeReferralInviteCenterProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 填码绑定、每日邀请者奖励上限和双方泥点发放都在同一事务内完成。 -#[spacetimedb::procedure] -pub fn redeem_profile_referral_invite_code( - ctx: &mut ProcedureContext, - input: RuntimeReferralRedeemInput, -) -> RuntimeReferralRedeemProcedureResult { - match ctx.try_with_tx(|tx| redeem_profile_referral_invite_code_record(tx, input.clone())) { - Ok(record) => RuntimeReferralRedeemProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeReferralRedeemProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 兑换码奖励、usage 与钱包流水必须在同一事务内落库,避免到账和计次分离。 -#[spacetimedb::procedure] -pub fn redeem_profile_reward_code( - ctx: &mut ProcedureContext, - input: RuntimeProfileRewardCodeRedeemInput, -) -> RuntimeProfileRewardCodeRedeemProcedureResult { - match ctx.try_with_tx(|tx| redeem_profile_reward_code_record(tx, input.clone())) { - Ok(record) => RuntimeProfileRewardCodeRedeemProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileRewardCodeRedeemProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_upsert_profile_redeem_code( - ctx: &mut ProcedureContext, - input: RuntimeProfileRedeemCodeAdminUpsertInput, -) -> RuntimeProfileRedeemCodeAdminProcedureResult { - match ctx.try_with_tx(|tx| admin_upsert_profile_redeem_code_record(tx, input.clone())) { - Ok(record) => RuntimeProfileRedeemCodeAdminProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_disable_profile_redeem_code( - ctx: &mut ProcedureContext, - input: RuntimeProfileRedeemCodeAdminDisableInput, -) -> RuntimeProfileRedeemCodeAdminProcedureResult { - match ctx.try_with_tx(|tx| admin_disable_profile_redeem_code_record(tx, input.clone())) { - Ok(record) => RuntimeProfileRedeemCodeAdminProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_list_profile_redeem_codes( - ctx: &mut ProcedureContext, - input: RuntimeProfileRedeemCodeAdminListInput, -) -> RuntimeProfileRedeemCodeAdminListProcedureResult { - match ctx.try_with_tx(|tx| admin_list_profile_redeem_code_records(tx, input.clone())) { - Ok((entries, operations)) => RuntimeProfileRedeemCodeAdminListProcedureResult { - ok: true, - entries, - operations, - error_message: None, - }, - Err(message) => RuntimeProfileRedeemCodeAdminListProcedureResult { - ok: false, - entries: Vec::new(), - operations: Vec::new(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_upsert_profile_invite_code( - ctx: &mut ProcedureContext, - input: RuntimeProfileInviteCodeAdminUpsertInput, -) -> RuntimeProfileInviteCodeAdminProcedureResult { - match ctx.try_with_tx(|tx| admin_upsert_profile_invite_code_record(tx, input.clone())) { - Ok(record) => RuntimeProfileInviteCodeAdminProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileInviteCodeAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_list_profile_invite_codes( - ctx: &mut ProcedureContext, - input: RuntimeProfileInviteCodeAdminListInput, -) -> RuntimeProfileInviteCodeAdminListProcedureResult { - match ctx.try_with_tx(|tx| admin_list_profile_invite_code_records(tx, input.clone())) { - Ok((entries, operations)) => RuntimeProfileInviteCodeAdminListProcedureResult { - ok: true, - entries, - operations, - error_message: None, - }, - Err(message) => RuntimeProfileInviteCodeAdminListProcedureResult { - ok: false, - entries: Vec::new(), - operations: Vec::new(), - error_message: Some(message), - }, - } -} - -pub(crate) fn list_profile_save_archive_rows( - ctx: &ReducerContext, - input: RuntimeProfileSaveArchiveListInput, -) -> Result, String> { - let validated_input = build_runtime_profile_save_archive_list_input(input.user_id) - .map_err(|error| error.to_string())?; - - let mut entries = ctx - .db - .profile_save_archive() - .by_profile_save_archive_user_id() - .filter(&validated_input.user_id) - .map(|row| build_profile_save_archive_snapshot_from_row(&row)) - .collect::>(); - - entries.sort_by(|left, right| { - right - .saved_at_micros - .cmp(&left.saved_at_micros) - .then_with(|| left.archive_id.cmp(&right.archive_id)) - }); - - Ok(entries) -} - -pub(crate) fn resume_profile_save_archive_record( - ctx: &ReducerContext, - input: RuntimeProfileSaveArchiveResumeInput, -) -> Result<(RuntimeProfileSaveArchiveSnapshot, RuntimeSnapshot), String> { - let validated_input = - build_runtime_profile_save_archive_resume_input(input.user_id, input.world_key) - .map_err(|error| error.to_string())?; - let archive = ctx - .db - .profile_save_archive() - .by_profile_save_archive_user_world_key() - .filter(( - validated_input.user_id.as_str(), - validated_input.world_key.as_str(), - )) - .next() - .ok_or_else(|| "profile_save_archive 对应 world_key 不存在".to_string())?; - - let existing_snapshot = ctx - .db - .runtime_snapshot() - .user_id() - .find(&validated_input.user_id); - let created_at = existing_snapshot - .as_ref() - .map(|row| row.created_at) - .unwrap_or(archive.saved_at); - - if let Some(existing) = existing_snapshot { - ctx.db - .runtime_snapshot() - .user_id() - .delete(&existing.user_id); - } - - ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow { - user_id: archive.user_id.clone(), - version: SAVE_SNAPSHOT_VERSION, - saved_at: archive.saved_at, - bottom_tab: archive.bottom_tab.clone(), - game_state_json: archive.game_state_json.clone(), - current_story_json: archive.current_story_json.clone(), - created_at, - updated_at: archive.saved_at, - }); - - Ok(( - build_profile_save_archive_snapshot_from_row(&archive), - RuntimeSnapshot { - user_id: archive.user_id.clone(), - version: SAVE_SNAPSHOT_VERSION, - saved_at_micros: archive.saved_at.to_micros_since_unix_epoch(), - bottom_tab: archive.bottom_tab.clone(), - game_state_json: archive.game_state_json.clone(), - current_story_json: archive.current_story_json.clone(), - created_at_micros: created_at.to_micros_since_unix_epoch(), - updated_at_micros: archive.saved_at.to_micros_since_unix_epoch(), - }, - )) -} - -pub(crate) fn sync_profile_projections_from_snapshot( - ctx: &ReducerContext, - snapshot: &RuntimeSnapshot, -) -> Result<(), String> { - let game_state = parse_json_str(&snapshot.game_state_json)?; - let game_state_object = game_state.as_object(); - let saved_at = Timestamp::from_micros_since_unix_epoch(snapshot.saved_at_micros); - - if module_runtime::is_non_persistent_runtime_snapshot(&game_state) { - return Ok(()); - } - - sync_profile_dashboard_from_snapshot(ctx, snapshot, game_state_object, saved_at); - sync_profile_save_archive_from_snapshot(ctx, snapshot, &game_state, saved_at)?; - - Ok(()) -} - -pub(crate) fn upsert_profile_played_work( - ctx: &ReducerContext, - input: ProfilePlayedWorkUpsertInput, -) -> Result<(), String> { - let user_id = input.user_id.trim(); - let world_key = input.world_key.trim(); - if user_id.is_empty() { - return Err("profile_played_world.user_id 不能为空".to_string()); - } - if world_key.is_empty() { - return Err("profile_played_world.world_key 不能为空".to_string()); - } - - let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros); - let played_world_id = build_runtime_profile_played_world_id(user_id, world_key); - let existing = ctx - .db - .profile_played_world() - .played_world_id() - .find(&played_world_id); - - if let Some(existing) = existing { - ctx.db - .profile_played_world() - .played_world_id() - .delete(&existing.played_world_id); - ctx.db.profile_played_world().insert(ProfilePlayedWorld { - played_world_id, - user_id: user_id.to_string(), - world_key: world_key.to_string(), - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - world_type: input.world_type, - world_title: input.world_title, - world_subtitle: input.world_subtitle, - first_played_at: existing.first_played_at, - last_played_at: played_at, - last_observed_play_time_ms: existing.last_observed_play_time_ms, - }); - } else { - ctx.db.profile_played_world().insert(ProfilePlayedWorld { - played_world_id, - user_id: user_id.to_string(), - world_key: world_key.to_string(), - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - world_type: input.world_type, - world_title: input.world_title, - world_subtitle: input.world_subtitle, - first_played_at: played_at, - last_played_at: played_at, - last_observed_play_time_ms: 0, - }); - } - - ensure_profile_dashboard_state(ctx, user_id, played_at); - Ok(()) -} - -pub(crate) fn add_profile_observed_play_time( - ctx: &ReducerContext, - user_id: &str, - world_key: &str, - elapsed_ms: u64, - observed_at_micros: i64, -) -> Result<(), String> { - let user_id = user_id.trim(); - let world_key = world_key.trim(); - if user_id.is_empty() || world_key.is_empty() || elapsed_ms == 0 { - return Ok(()); - } - - let observed_at = Timestamp::from_micros_since_unix_epoch(observed_at_micros); - let played_world_id = build_runtime_profile_played_world_id(user_id, world_key); - if let Some(existing) = ctx - .db - .profile_played_world() - .played_world_id() - .find(&played_world_id) - { - ctx.db - .profile_played_world() - .played_world_id() - .delete(&existing.played_world_id); - ctx.db.profile_played_world().insert(ProfilePlayedWorld { - played_world_id, - user_id: existing.user_id, - world_key: existing.world_key, - owner_user_id: existing.owner_user_id, - profile_id: existing.profile_id, - world_type: existing.world_type, - world_title: existing.world_title, - world_subtitle: existing.world_subtitle, - first_played_at: existing.first_played_at, - last_played_at: observed_at, - last_observed_play_time_ms: existing - .last_observed_play_time_ms - .saturating_add(elapsed_ms), - }); - } - - add_profile_dashboard_play_time(ctx, user_id, elapsed_ms, observed_at); - Ok(()) -} - -pub(crate) fn upsert_profile_save_archive( - ctx: &ReducerContext, - input: ProfileSaveArchiveUpsertInput, -) -> Result<(), String> { - let user_id = input.user_id.trim(); - let world_key = input.world_key.trim(); - if user_id.is_empty() || world_key.is_empty() { - return Err("profile_save_archive 参数不能为空".to_string()); - } - - let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros); - let archive_id = format!("{user_id}:{world_key}"); - let existing = ctx.db.profile_save_archive().archive_id().find(&archive_id); - let created_at = existing - .as_ref() - .map(|row| row.created_at) - .unwrap_or(saved_at); - - if let Some(existing) = existing { - ctx.db - .profile_save_archive() - .archive_id() - .delete(&existing.archive_id); - } - - ctx.db.profile_save_archive().insert(ProfileSaveArchive { - archive_id, - user_id: user_id.to_string(), - world_key: world_key.to_string(), - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - world_type: input.world_type, - world_name: input.world_name, - subtitle: input.subtitle, - summary_text: input.summary_text, - cover_image_src: input.cover_image_src, - saved_at, - bottom_tab: input.bottom_tab, - game_state_json: input.game_state_json, - current_story_json: input.current_story_json, - created_at, - updated_at: saved_at, - }); - - Ok(()) -} - -pub(crate) fn record_public_work_play( - ctx: &ReducerContext, - input: PublicWorkPlayRecordInput, -) -> Result<(), String> { - let source_type = input.source_type.trim(); - let owner_user_id = input.owner_user_id.trim(); - let profile_id = input.profile_id.trim(); - if source_type.is_empty() || owner_user_id.is_empty() || profile_id.is_empty() { - return Err("public_work_play_daily_stat 参数不能为空".to_string()); - } - - let played_day = public_work_play_day_from_micros(input.played_at_micros); - let stat_id = build_public_work_play_daily_stat_id(source_type, profile_id, played_day); - let updated_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros); - let next_count = ctx - .db - .public_work_play_daily_stat() - .stat_id() - .find(&stat_id) - .map(|existing| { - ctx.db - .public_work_play_daily_stat() - .stat_id() - .delete(&existing.stat_id); - existing.play_count.saturating_add(1) - }) - .unwrap_or(1); - - ctx.db - .public_work_play_daily_stat() - .insert(PublicWorkPlayDailyStat { - stat_id, - source_type: source_type.to_string(), - owner_user_id: owner_user_id.to_string(), - profile_id: profile_id.to_string(), - played_day, - play_count: next_count, - updated_at, - }); - - Ok(()) -} - -pub(crate) fn record_public_work_like( - ctx: &ReducerContext, - input: PublicWorkLikeRecordInput, -) -> Result { - let source_type = input.source_type.trim(); - let owner_user_id = input.owner_user_id.trim(); - let profile_id = input.profile_id.trim(); - let user_id = input.user_id.trim(); - if source_type.is_empty() - || owner_user_id.is_empty() - || profile_id.is_empty() - || user_id.is_empty() - { - return Err("public_work_like 参数不能为空".to_string()); - } - - let like_id = build_public_work_like_id(source_type, profile_id, user_id); - if ctx.db.public_work_like().like_id().find(&like_id).is_some() { - return Ok(false); - } - - ctx.db.public_work_like().insert(PublicWorkLike { - like_id, - source_type: source_type.to_string(), - owner_user_id: owner_user_id.to_string(), - profile_id: profile_id.to_string(), - user_id: user_id.to_string(), - liked_at: Timestamp::from_micros_since_unix_epoch(input.liked_at_micros), - }); - - Ok(true) -} - -pub(crate) fn count_recent_public_work_plays( - ctx: &ReducerContext, - source_type: &str, - profile_id: &str, - now_micros: i64, -) -> u32 { - count_recent_public_work_plays_for_profiles( - ctx, - source_type, - &[profile_id.to_string()], - now_micros, - ) - .remove(profile_id.trim()) - .unwrap_or(0) -} - -pub(crate) fn count_recent_public_work_plays_for_profiles( - ctx: &ReducerContext, - source_type: &str, - profile_ids: &[String], - now_micros: i64, -) -> HashMap { - let source_type = source_type.trim(); - if source_type.is_empty() || profile_ids.is_empty() { - return HashMap::new(); - } - - let current_day = public_work_play_day_from_micros(now_micros); - let first_day = current_day.saturating_sub(PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1); - let requested_profile_ids = profile_ids - .iter() - .map(|profile_id| profile_id.trim()) - .filter(|profile_id| !profile_id.is_empty()) - .collect::>(); - let mut counts = HashMap::new(); - - for profile_id in requested_profile_ids { - let mut total = 0u32; - for played_day in first_day..=current_day { - let day_total = ctx - .db - .public_work_play_daily_stat() - .by_public_work_play_daily_stat_work_day() - .filter((source_type, profile_id, played_day)) - .fold(0u32, |sum, row| sum.saturating_add(row.play_count)); - total = total.saturating_add(day_total); - } - if total > 0 { - counts.insert(profile_id.to_string(), total); - } - } - - counts -} - -#[cfg(test)] -fn build_recent_public_work_play_counts( - rows: impl IntoIterator, - source_type: &str, - profile_ids: &[String], - now_micros: i64, -) -> HashMap { - let source_type = source_type.trim(); - if source_type.is_empty() || profile_ids.is_empty() { - return HashMap::new(); - } - - let requested_profile_ids = profile_ids - .iter() - .map(|profile_id| profile_id.trim()) - .filter(|profile_id| !profile_id.is_empty()) - .collect::>(); - if requested_profile_ids.is_empty() { - return HashMap::new(); - } - - let current_day = public_work_play_day_from_micros(now_micros); - let first_day = current_day.saturating_sub(PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1); - let mut counts = HashMap::new(); - - for row in rows { - if row.source_type != source_type - || !requested_profile_ids.contains(row.profile_id.as_str()) - || row.played_day < first_day - || row.played_day > current_day - { - continue; - } - - let entry = counts.entry(row.profile_id.clone()).or_insert(0u32); - *entry = entry.saturating_add(row.play_count); - } - - counts -} - -fn public_work_play_day_from_micros(value: i64) -> i64 { - value.div_euclid(PUBLIC_WORK_PLAY_DAY_MICROS) -} - -fn build_public_work_play_daily_stat_id( - source_type: &str, - profile_id: &str, - played_day: i64, -) -> String { - format!("{source_type}:{profile_id}:{played_day}") -} - -fn build_public_work_like_id(source_type: &str, profile_id: &str, user_id: &str) -> String { - format!("{source_type}:{profile_id}:{user_id}") -} - -#[cfg(test)] -mod tests { - use super::*; - - fn wallet_ledger_snapshot( - ledger_id: &str, - amount_delta: i64, - balance_after: u64, - created_at_micros: i64, - ) -> RuntimeProfileWalletLedgerEntrySnapshot { - RuntimeProfileWalletLedgerEntrySnapshot { - wallet_ledger_id: ledger_id.to_string(), - user_id: "user-1".to_string(), - amount_delta, - balance_after, - source_type: RuntimeProfileWalletLedgerSourceType::PointsRecharge, - created_at_micros, - metadata_json: "{}".to_string(), - } - } - - #[test] - fn wallet_ledger_sort_follows_balance_settlement_chain_when_payment_time_is_delayed() { - let mut entries = vec![ - wallet_ledger_snapshot("daily-free", 20, 97, 1), - wallet_ledger_snapshot("recharge-180-delayed", 180, 607, 2), - wallet_ledger_snapshot("recharge-60", 60, 157, 3), - wallet_ledger_snapshot("recharge-270", 270, 427, 4), - ]; - - sort_profile_wallet_ledger_entries(&mut entries, 607); - - assert_eq!( - entries - .iter() - .map(|entry| entry.wallet_ledger_id.as_str()) - .collect::>(), - vec![ - "recharge-180-delayed", - "recharge-270", - "recharge-60", - "daily-free", - ] - ); - } - - #[test] - fn wallet_ledger_records_settlement_time_instead_of_delayed_business_event_time() { - let paid_at = Timestamp::from_micros_since_unix_epoch(100); - let settled_at = Timestamp::from_micros_since_unix_epoch(200); - - assert_eq!( - profile_wallet_ledger_recorded_at(paid_at, settled_at), - settled_at - ); - } - - fn asset_operation_wallet_ledger( - ledger_id: &str, - user_id: &str, - amount_delta: i64, - source_type: RuntimeProfileWalletLedgerSourceType, - ) -> ProfileWalletLedger { - ProfileWalletLedger { - wallet_ledger_id: ledger_id.to_string(), - user_id: user_id.to_string(), - amount_delta, - balance_after: 100, - source_type, - created_at: Timestamp::from_micros_since_unix_epoch(1), - metadata_json: None, - } - } - - #[test] - fn asset_operation_refund_without_matching_consume_records_settlement_intent() { - assert_eq!( - resolve_asset_operation_refund_disposition( - "user-1", - 37, - "asset_operation_refund:external_generation_job:job-1:attempt:1", - None, - None, - None, - ), - Ok(AssetOperationRefundDisposition::RecordIntent) - ); - } - - #[test] - fn asset_operation_refund_is_idempotent_and_requires_matching_consume() { - let consume = asset_operation_wallet_ledger( - "asset_operation_consume:external_generation_job:job-1:attempt:1", - "user-1", - -37, - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, - ); - let refund = asset_operation_wallet_ledger( - "asset_operation_refund:external_generation_job:job-1:attempt:1", - "user-1", - 37, - RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, - ); - - assert_eq!( - resolve_asset_operation_refund_disposition( - "user-1", - 37, - &refund.wallet_ledger_id, - None, - None, - Some(&consume), - ), - Ok(AssetOperationRefundDisposition::Apply) - ); - assert_eq!( - resolve_asset_operation_refund_disposition( - "user-1", - 37, - &refund.wallet_ledger_id, - None, - Some(&refund), - Some(&consume), - ), - Ok(AssetOperationRefundDisposition::Noop) - ); - assert!( - resolve_asset_operation_refund_disposition( - "user-2", - 37, - &refund.wallet_ledger_id, - None, - None, - Some(&consume), - ) - .is_err() - ); - assert!( - resolve_asset_operation_refund_disposition( - "user-1", - 38, - &refund.wallet_ledger_id, - None, - None, - Some(&consume), - ) - .is_err() - ); - } - - #[test] - fn asset_operation_settlement_intent_rejects_conflicting_late_operations() { - let consume_ledger_id = "asset_operation_consume:external_generation_job:job-1:attempt:1"; - let refund_ledger_id = "asset_operation_refund:external_generation_job:job-1:attempt:1"; - let settlement = AssetOperationWalletSettlement { - consume_ledger_id: consume_ledger_id.to_string(), - refund_ledger_id: refund_ledger_id.to_string(), - user_id: "user-1".to_string(), - amount: 37, - settled_at: Timestamp::from_micros_since_unix_epoch(1), - }; - - assert_eq!( - resolve_asset_operation_refund_disposition( - "user-1", - 37, - refund_ledger_id, - Some(&settlement), - None, - None, - ), - Ok(AssetOperationRefundDisposition::Noop) - ); - assert!( - validate_asset_operation_wallet_settlement( - &settlement, - consume_ledger_id, - refund_ledger_id, - "user-2", - 37, - ) - .is_err() - ); - assert!( - validate_asset_operation_wallet_settlement( - &settlement, - consume_ledger_id, - refund_ledger_id, - "user-1", - 38, - ) - .is_err() - ); - } - - #[test] - fn wallet_idempotent_replay_requires_matching_user_amount_and_source() { - let existing = asset_operation_wallet_ledger( - "asset_operation_consume:external_generation_job:job-1:attempt:1", - "user-1", - -37, - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, - ); - - assert!( - validate_idempotent_profile_wallet_ledger( - &existing, - "user-1", - -37, - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, - ) - .is_ok() - ); - assert!( - validate_idempotent_profile_wallet_ledger( - &existing, - "user-2", - -37, - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, - ) - .is_err() - ); - assert!( - validate_idempotent_profile_wallet_ledger( - &existing, - "user-1", - -38, - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, - ) - .is_err() - ); - assert!( - validate_idempotent_profile_wallet_ledger( - &existing, - "user-1", - -37, - RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, - ) - .is_err() - ); - } - - fn recharge_order_for_refund( - status: RuntimeProfileRechargeOrderStatus, - ) -> ProfileRechargeOrder { - ProfileRechargeOrder { - order_id: "rcg-test".to_string(), - user_id: "user-1".to_string(), - product_id: "points_60".to_string(), - product_title: "60泥点".to_string(), - kind: RuntimeProfileRechargeProductKind::Points, - amount_cents: 600, - status, - payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE.to_string(), - paid_at: Some(Timestamp::from_micros_since_unix_epoch(100)), - provider_transaction_id: Some("wx-transaction-1".to_string()), - created_at: Timestamp::from_micros_since_unix_epoch(1), - points_delta: 60, - membership_expires_at: None, - expired_at: None, - expiration_checked_at: None, - expiration_provider_state: None, - expiration_last_error: None, - } - } - - fn recharge_refund_for_order(order: &ProfileRechargeOrder) -> ProfileRechargeRefund { - ProfileRechargeRefund { - out_refund_no: "refund-test".to_string(), - provider_refund_id: "wx-refund-1".to_string(), - order_id: order.order_id.clone(), - provider_transaction_id: order.provider_transaction_id.clone().unwrap(), - user_id: Some(order.user_id.clone()), - provider_status: RuntimeProfileRechargeRefundStatus::Success, - total_cents: order.amount_cents, - refund_cents: order.amount_cents, - payer_total_cents: order.amount_cents, - payer_refund_cents: order.amount_cents, - success_at: Some(Timestamp::from_micros_since_unix_epoch(200)), - first_observed_at: Timestamp::from_micros_since_unix_epoch(200), - updated_at: Timestamp::from_micros_since_unix_epoch(200), - last_observation_source: RuntimeProfileRechargeRefundObservationSource::Callback, - last_observation_id: "observation-1".to_string(), - order_settled_at: None, - target_recovery_points: 0, - recovered_points: 0, - unrecovered_points: 0, - recovery_status: RuntimeProfileRechargeRefundRecoveryStatus::Pending, - last_recovery_ledger_id: None, - last_error_code: None, - manual_review_resolved_by_admin_user_id: None, - manual_review_resolution_reason: None, - manual_review_resolved_at: None, - manual_review_resolved_error_code: None, - } - } - - #[test] - fn refunded_order_accepts_only_exact_payment_transaction_replay() { - let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Refunded); - assert!( - validate_profile_recharge_order_paid_replay_transaction_id( - &order, - &Some("wx-transaction-1".to_string()), - ) - .is_ok() - ); - assert!( - validate_profile_recharge_order_paid_replay_transaction_id( - &order, - &Some("wx-transaction-other".to_string()), - ) - .is_err() - ); - assert!(validate_profile_recharge_order_paid_replay_transaction_id(&order, &None).is_err()); - } - - #[test] - fn confirmed_manual_review_suppresses_only_the_approved_order_match_conflict() { - let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid); - let mut refund = recharge_refund_for_order(&order); - refund.provider_transaction_id = "wx-transaction-other".to_string(); - assert_eq!( - unresolved_profile_recharge_refund_order_match_error(&order, &refund), - Some("provider_transaction_id_mismatch".to_string()) - ); - - refund.manual_review_resolved_at = Some(Timestamp::from_micros_since_unix_epoch(300)); - refund.manual_review_resolved_error_code = - Some("provider_transaction_id_mismatch".to_string()); - assert_eq!( - unresolved_profile_recharge_refund_order_match_error(&order, &refund), - None - ); - assert_eq!( - validate_profile_recharge_refund_order_match(&order, &refund), - Err("provider_transaction_id_mismatch".to_string()) - ); - - refund.total_cents = order.amount_cents + 1; - assert_eq!( - unresolved_profile_recharge_refund_order_match_error(&order, &refund), - Some("order_total_mismatch".to_string()) - ); - - refund.manual_review_resolved_error_code = Some("order_total_mismatch".to_string()); - assert_eq!( - unresolved_profile_recharge_refund_order_match_error(&order, &refund), - Some("provider_transaction_id_mismatch".to_string()) - ); - } - - #[test] - fn manual_review_request_matches_current_and_resolved_state() { - let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid); - let mut refund = recharge_refund_for_order(&order); - refund.last_error_code = Some("provider_transaction_id_mismatch".to_string()); - - assert!( - validate_profile_recharge_refund_manual_review_request( - &refund, - "provider_transaction_id_mismatch", - "admin-1", - "已核对商户平台", - ) - .is_ok() - ); - assert!( - validate_profile_recharge_refund_manual_review_request( - &refund, - "order_total_mismatch", - "admin-1", - "已核对商户平台", - ) - .is_err() - ); - - refund.manual_review_resolved_at = Some(Timestamp::from_micros_since_unix_epoch(300)); - refund.manual_review_resolved_error_code = Some("order_total_mismatch".to_string()); - refund.manual_review_resolved_by_admin_user_id = Some("admin-1".to_string()); - refund.manual_review_resolution_reason = Some("已核对商户平台".to_string()); - assert!( - validate_profile_recharge_refund_manual_review_request( - &refund, - "order_total_mismatch", - "admin-1", - "已核对商户平台", - ) - .is_ok() - ); - assert!( - validate_profile_recharge_refund_manual_review_request( - &refund, - "provider_transaction_id_mismatch", - "admin-1", - "已核对商户平台", - ) - .is_err() - ); - assert!( - validate_profile_recharge_refund_manual_review_request( - &refund, - "order_total_mismatch", - "admin-2", - "已核对商户平台", - ) - .is_err() - ); - assert!( - validate_profile_recharge_refund_manual_review_request( - &refund, - "order_total_mismatch", - "admin-1", - "另一份核对意见", - ) - .is_err() - ); - } - - #[test] - fn refunded_order_keeps_first_recharge_qualification_consumed() { - let refunded = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Refunded); - assert!(profile_recharge_order_counts_as_paid_purchase(&refunded)); - - let mut unpaid = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Pending); - unpaid.paid_at = None; - assert!(!profile_recharge_order_counts_as_paid_purchase(&unpaid)); - } - - #[test] - fn v3_refund_order_match_rejects_virtual_channel_transaction_and_total_mismatch() { - let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid); - let refund = recharge_refund_for_order(&order); - assert!(validate_profile_recharge_refund_order_match(&order, &refund).is_ok()); - - let mut wrong_channel = order.clone(); - wrong_channel.payment_channel = - PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL.to_string(); - assert_eq!( - validate_profile_recharge_refund_order_match(&wrong_channel, &refund), - Err("payment_channel_not_v3".to_string()) - ); - - let mut wrong_transaction = refund.clone(); - wrong_transaction.provider_transaction_id = "wx-transaction-other".to_string(); - assert_eq!( - validate_profile_recharge_refund_order_match(&order, &wrong_transaction), - Err("provider_transaction_id_mismatch".to_string()) - ); - - let mut wrong_total = refund; - wrong_total.total_cents = 599; - assert_eq!( - validate_profile_recharge_refund_order_match(&order, &wrong_total), - Err("order_total_mismatch".to_string()) - ); - } - - #[test] - fn successful_point_refund_conflict_freezes_ordinary_wallet_debits() { - for code in [ - "provider_transaction_id_mismatch", - "order_total_mismatch", - "refund_settlement_plan_invalid", - ] { - assert!(profile_recharge_refund_manual_review_freezes_wallet( - RuntimeProfileRechargeProductKind::Points, - code, - )); - assert!(!profile_recharge_refund_manual_review_freezes_wallet( - RuntimeProfileRechargeProductKind::Membership, - code, - )); - } - - let settlement = ProfileRechargeOrderRefundSettlement { - order_id: "rcg-test".to_string(), - user_id: "user-1".to_string(), - successful_refund_count: 0, - cumulative_success_refund_cents: 0, - target_recovery_points: 0, - recovered_points: 0, - unrecovered_points: 0, - recovery_status: RuntimeProfileRechargeRefundRecoveryStatus::ManualReview, - wallet_frozen: true, - updated_at: Timestamp::from_micros_since_unix_epoch(200), - }; - let refund_frozen = profile_recharge_refund_settlement_freezes_wallet(&settlement); - assert!(refund_frozen); - assert!( - validate_runtime_profile_wallet_debit_restrictions( - -1, - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, - false, - refund_frozen, - ) - .is_err() - ); - } - - #[test] - fn refund_observation_replay_requires_identical_financial_facts() { - let input = build_runtime_profile_recharge_refund_observation_input( - "observation-1".to_string(), - RuntimeProfileRechargeRefundObservationSource::Callback, - Some("notification-ref".to_string()), - "payload-ref".to_string(), - "refund-test".to_string(), - "wx-refund-1".to_string(), - "rcg-test".to_string(), - "wx-transaction-1".to_string(), - RuntimeProfileRechargeRefundStatus::Success, - 600, - 600, - 600, - 600, - Some(200), - 300, - ) - .unwrap(); - let row = ProfileRechargeRefundObservation { - observation_id: input.observation_id.clone(), - out_refund_no: input.out_refund_no.clone(), - provider_refund_id: input.provider_refund_id.clone(), - order_id: input.order_id.clone(), - provider_transaction_id: input.provider_transaction_id.clone(), - source: input.source, - provider_status: input.provider_status, - total_cents: input.total_cents, - refund_cents: input.refund_cents, - payer_total_cents: input.payer_total_cents, - payer_refund_cents: input.payer_refund_cents, - success_at: input - .success_at_micros - .map(Timestamp::from_micros_since_unix_epoch), - notification_ref: input.notification_ref.clone(), - payload_fingerprint: input.payload_fingerprint.clone(), - resolution_code: "settled".to_string(), - observed_at: Timestamp::from_micros_since_unix_epoch(input.observed_at_micros), - }; - assert!(validate_profile_recharge_refund_observation_replay(&row, &input).is_ok()); - - let mut conflicting = input; - conflicting.refund_cents = 599; - assert!(validate_profile_recharge_refund_observation_replay(&row, &conflicting).is_err()); - let snapshot = build_profile_recharge_refund_observation_snapshot_from_row(&row); - assert_eq!(snapshot.resolution_code, "settled"); - } - - #[test] - fn refund_reconciliation_retries_only_late_order_manual_review() { - let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid); - let mut refund = recharge_refund_for_order(&order); - refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; - - refund.last_error_code = Some("order_missing".to_string()); - assert!(profile_recharge_refund_needs_reconciliation(&refund)); - refund.last_error_code = Some("order_not_paid".to_string()); - assert!(profile_recharge_refund_needs_reconciliation(&refund)); - - refund.last_error_code = Some("membership_manual_review".to_string()); - assert!(!profile_recharge_refund_needs_reconciliation(&refund)); - refund.last_error_code = Some("order_total_mismatch".to_string()); - assert!(!profile_recharge_refund_needs_reconciliation(&refund)); - } - - #[test] - fn refund_reconciliation_rotates_across_more_than_one_batch() { - let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid); - let rows = (0..250) - .map(|index| { - let mut refund = recharge_refund_for_order(&order); - refund.out_refund_no = format!("refund-{index:03}"); - refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::Shortfall; - refund - }) - .collect::>(); - - let first = select_profile_recharge_refund_reconciliation_page(rows.clone(), 100, 0); - let second = select_profile_recharge_refund_reconciliation_page(rows.clone(), 100, 1); - let third = select_profile_recharge_refund_reconciliation_page(rows, 100, 2); - assert_eq!((first.len(), second.len(), third.len()), (100, 100, 50)); - - let observed = first - .into_iter() - .chain(second) - .chain(third) - .map(|row| row.out_refund_no) - .collect::>(); - assert_eq!(observed.len(), 250); - } - - #[test] - fn point_recharge_display_is_resolved_per_product() { - let products = runtime_profile_recharge_point_products(); - let repeated = resolve_profile_recharge_product_display(products[0].clone(), true); - let untouched = resolve_profile_recharge_product_display(products[1].clone(), false); - - assert_eq!(repeated.product_id, "points_60"); - assert_eq!(repeated.bonus_points, 0); - assert_eq!(repeated.badge_label, ""); - assert_eq!(repeated.description, "60泥点"); - assert_eq!(untouched.product_id, "points_180"); - assert_eq!(untouched.bonus_points, 90); - assert_eq!(untouched.badge_label, "首充加赠"); - assert_eq!(untouched.description, "首充加赠90泥点"); - } - - #[test] - fn legacy_default_recharge_products_migrate_without_overwriting_admin_config() { - let timestamp = Timestamp::from_micros_since_unix_epoch(1_000_000); - let build_row = |points_amount: u64| ProfileRechargeProductConfig { - product_id: format!("points_{points_amount}"), - title: format!("{points_amount}泥点"), - price_cents: points_amount * 10, - kind: RuntimeProfileRechargeProductKind::Points, - points_amount, - bonus_points: points_amount, - duration_days: 0, - badge_label: "首充双倍".to_string(), - description: format!("首充送{points_amount}泥点"), - tier: RuntimeProfileMembershipTier::Normal, - enabled: true, - sort_order: 0, - created_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(), - created_at: timestamp, - updated_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(), - updated_at: timestamp, - membership_period_points: 0, - membership_period_days: 0, - membership_queue_limit: 0, - membership_discount_bps: 0, - }; - - assert_eq!( - resolve_default_point_product_migration(&build_row(180)), - Some(DefaultPointProductMigration { - bonus_points: 90, - badge_label: "首充加赠", - description: "首充加赠90泥点", - enabled: true, - }), - ); - assert_eq!( - resolve_default_point_product_migration(&build_row(1_280)), - Some(DefaultPointProductMigration { - bonus_points: 1_280, - badge_label: "首充双倍", - description: "", - enabled: false, - }), - ); - - let mut admin_row = build_row(300); - admin_row.updated_by = "admin-1".to_string(); - assert_eq!(resolve_default_point_product_migration(&admin_row), None); - } - - #[test] - fn membership_wallet_split_metadata_keeps_cycle_reset_for_refund_restore() { - let metadata = metadata_with_profile_wallet_consumption_split( - r#"{"externalGenerationJobId":"job-1"}"#, - 150, - DailyFreePointMutation::none(), - MembershipCyclePointMutation { - points: 120, - cycle_resets_at_micros: Some(123_000), - }, - ); - let parsed = serde_json::from_str::(&metadata).expect("metadata json"); - - assert_eq!( - parsed - .get("externalGenerationJobId") - .and_then(JsonValue::as_str), - Some("job-1"), - ); - assert_eq!( - parsed - .get("dailyFreePointsDelta") - .and_then(JsonValue::as_i64), - Some(0), - ); - assert_eq!( - parsed - .get("membershipPeriodPointsDelta") - .and_then(JsonValue::as_i64), - Some(-120), - ); - assert_eq!( - parsed - .get("permanentPointsDelta") - .and_then(JsonValue::as_i64), - Some(-30), - ); - assert_eq!( - parsed - .get("cycleResetsAtMicros") - .and_then(JsonValue::as_i64), - Some(123_000), - ); - assert_eq!( - membership_refund_restore_candidate_from_consume_metadata(&metadata), - MembershipCyclePointMutation { - points: 120, - cycle_resets_at_micros: Some(123_000), - }, - ); - } - - #[test] - fn wallet_split_consumes_daily_free_before_membership_and_permanent_points() { - let metadata = metadata_with_profile_wallet_consumption_split( - "{}", - 150, - DailyFreePointMutation { - points: 20, - day_key: Some(20_280), - }, - MembershipCyclePointMutation { - points: 120, - cycle_resets_at_micros: Some(456_000), - }, - ); - let parsed = serde_json::from_str::(&metadata).expect("metadata json"); - - assert_eq!(parsed["dailyFreePointsDelta"], json!(-20)); - assert_eq!(parsed["dailyFreeDayKey"], json!(20_280)); - assert_eq!(parsed["membershipPeriodPointsDelta"], json!(-120)); - assert_eq!(parsed["permanentPointsDelta"], json!(-10)); - assert_eq!( - daily_free_refund_restore_candidate_from_consume_metadata(&metadata), - DailyFreePointMutation { - points: 20, - day_key: Some(20_280), - }, - ); - } - - #[test] - fn cross_day_daily_free_refund_keeps_the_original_permanent_remainder_permanent() { - let metadata = metadata_with_profile_wallet_refund_split( - "{}", - 30, - DailyFreePointMutation { - points: 20, - day_key: Some(20_281), - }, - MembershipCyclePointMutation::none(), - ); - let parsed = serde_json::from_str::(&metadata).expect("metadata json"); - - assert_eq!(parsed["dailyFreePointsDelta"], json!(20)); - assert_eq!(parsed["dailyFreeDayKey"], json!(20_281)); - assert_eq!(parsed["membershipPeriodPointsDelta"], json!(0)); - assert_eq!(parsed["permanentPointsDelta"], json!(10)); - } - - #[test] - fn daily_free_refund_restore_candidate_requires_a_negative_delta_and_day_key() { - assert_eq!( - daily_free_refund_restore_candidate_from_consume_metadata( - r#"{"dailyFreePointsDelta":-20,"dailyFreeDayKey":20280}"#, - ), - DailyFreePointMutation { - points: 20, - day_key: Some(20_280), - }, - ); - assert_eq!( - daily_free_refund_restore_candidate_from_consume_metadata( - r#"{"dailyFreePointsDelta":0,"dailyFreeDayKey":20280}"#, - ), - DailyFreePointMutation::none(), - ); - assert_eq!( - daily_free_refund_restore_candidate_from_consume_metadata("not-json"), - DailyFreePointMutation::none(), - ); - } - - #[test] - fn daily_free_refund_restores_same_day_and_stacks_after_cross_day() { - assert_eq!( - resolve_daily_free_refund_restore_plan(20_280, 20_280, 20_280, 20, 5, 20, 20), - Some(DailyFreeRefundRestorePlan { - restored_points: 15, - target_day_key: 20_280, - granted_points_delta: 0, - }), - ); - assert_eq!( - resolve_daily_free_refund_restore_plan(20_280, 20_281, 20_281, 20, 20, 20, 20), - Some(DailyFreeRefundRestorePlan { - restored_points: 20, - target_day_key: 20_281, - granted_points_delta: 20, - }), - ); - assert_eq!( - resolve_daily_free_refund_restore_plan(20_279, 20_281, 20_281, 40, 40, 20, 20), - Some(DailyFreeRefundRestorePlan { - restored_points: 20, - target_day_key: 20_281, - granted_points_delta: 20, - }), - ); - assert_eq!( - resolve_daily_free_refund_restore_plan(20_281, 20_281, 20_281, 60, 50, 10, 10), - Some(DailyFreeRefundRestorePlan { - restored_points: 10, - target_day_key: 20_281, - granted_points_delta: 0, - }), - ); - assert_eq!( - resolve_daily_free_refresh_plan(Some(20_281), 60, 20_282), - Some(DailyFreeRefreshPlan { - expired_points: 60, - granted_points: 20, - reset: true, - }), - ); - assert_eq!( - resolve_daily_free_refund_restore_plan(20_282, 20_281, 20_281, 20, 20, 20, 20), - None, - ); - } - - #[test] - fn daily_free_reset_boundary_is_beijing_midnight() { - let day_key = 20_280; - let resets_at_micros = profile_daily_free_points_resets_at_micros(day_key); - - assert_eq!(PROFILE_DAILY_FREE_POINTS_PER_DAY, 20); - assert_eq!( - runtime_profile_beijing_day_key(resets_at_micros.saturating_sub(1)), - day_key, - ); - assert_eq!( - runtime_profile_beijing_day_key(resets_at_micros), - day_key + 1, - ); - } - - #[test] - fn daily_free_refresh_plan_grants_once_and_replaces_cross_day_remainder() { - assert_eq!( - resolve_daily_free_refresh_plan(None, 0, 20_280), - Some(DailyFreeRefreshPlan { - expired_points: 0, - granted_points: 20, - reset: false, - }), - ); - assert_eq!( - resolve_daily_free_refresh_plan(Some(20_280), 7, 20_280), - None, - ); - assert_eq!( - resolve_daily_free_refresh_plan(Some(20_280), 7, 20_281), - Some(DailyFreeRefreshPlan { - expired_points: 7, - granted_points: 20, - reset: true, - }), - ); - assert_eq!( - resolve_daily_free_refresh_plan(Some(20_281), 7, 20_280), - None, - ); - } - - #[test] - fn legacy_wallet_snapshot_keeps_current_daily_free_points() { - assert_eq!( - merge_legacy_wallet_balance_with_daily_free_points(100, 20), - 120, - ); - assert_eq!( - merge_legacy_wallet_balance_with_daily_free_points(100, 0), - 100, - ); - assert!(!profile_wallet_ledger_source_blocks_legacy_snapshot_sync( - RuntimeProfileWalletLedgerSourceType::DailyFreeGrant, - )); - assert!(!profile_wallet_ledger_source_blocks_legacy_snapshot_sync( - RuntimeProfileWalletLedgerSourceType::DailyFreeReset, - )); - assert!(profile_wallet_ledger_source_blocks_legacy_snapshot_sync( - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, - )); - } - - #[test] - fn membership_refund_restore_candidate_requires_negative_cycle_delta() { - assert_eq!( - membership_refund_restore_candidate_from_consume_metadata( - r#"{"membershipPeriodPointsDelta":0,"cycleResetsAtMicros":123}"#, - ), - MembershipCyclePointMutation::none(), - ); - assert_eq!( - membership_refund_restore_candidate_from_consume_metadata( - r#"{"membershipPeriodPointsDelta":50,"cycleResetsAtMicros":123}"#, - ), - MembershipCyclePointMutation::none(), - ); - assert_eq!( - membership_refund_restore_candidate_from_consume_metadata("not-json"), - MembershipCyclePointMutation::none(), - ); - } - - #[test] - fn membership_upgrade_amount_only_charges_price_difference() { - assert_eq!(membership_upgrade_amount_cents(6_990, 1_990), 5_000); - assert_eq!(membership_upgrade_amount_cents(19_990, 6_990), 13_000); - } - - #[test] - fn legacy_membership_tiers_share_new_pricing_ranks() { - assert_eq!( - canonical_membership_pricing_tier(RuntimeProfileMembershipTier::Month), - RuntimeProfileMembershipTier::Starter, - ); - assert_eq!( - canonical_membership_pricing_tier(RuntimeProfileMembershipTier::Season), - RuntimeProfileMembershipTier::Basic, - ); - assert_eq!( - canonical_membership_pricing_tier(RuntimeProfileMembershipTier::Year), - RuntimeProfileMembershipTier::Pro, - ); - } - - #[test] - fn legacy_membership_tiers_can_migrate_to_new_equivalent_tiers_as_renewals() { - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Month, - RuntimeProfileMembershipTier::Starter, - ), - Ok(ActiveMembershipPurchaseMode::Renew), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Season, - RuntimeProfileMembershipTier::Basic, - ), - Ok(ActiveMembershipPurchaseMode::Renew), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Year, - RuntimeProfileMembershipTier::Pro, - ), - Ok(ActiveMembershipPurchaseMode::Renew), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Starter, - RuntimeProfileMembershipTier::Starter, - ), - Ok(ActiveMembershipPurchaseMode::Renew), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Month, - RuntimeProfileMembershipTier::Basic, - ), - Ok(ActiveMembershipPurchaseMode::Upgrade), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Season, - RuntimeProfileMembershipTier::Pro, - ), - Ok(ActiveMembershipPurchaseMode::Upgrade), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Year, - RuntimeProfileMembershipTier::Ultimate, - ), - Ok(ActiveMembershipPurchaseMode::Upgrade), - ); - assert!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Basic, - RuntimeProfileMembershipTier::Starter, - ) - .is_err() - ); - assert!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Year, - RuntimeProfileMembershipTier::Basic, - ) - .is_err() - ); - assert!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Basic, - RuntimeProfileMembershipTier::Season, - ) - .is_err() - ); - } - - #[test] - fn legacy_membership_migration_renews_and_preserves_cycle_timing() { - let started_at = Timestamp::from_micros_since_unix_epoch(PROFILE_RUNTIME_DAY_MICROS); - let expires_at = Timestamp::from_micros_since_unix_epoch(10 * PROFILE_RUNTIME_DAY_MICROS); - let current_reset_at = - Timestamp::from_micros_since_unix_epoch(5 * PROFILE_RUNTIME_DAY_MICROS); - let purchased_at = Timestamp::from_micros_since_unix_epoch(2 * PROFILE_RUNTIME_DAY_MICROS); - let row = apply_active_membership_renew_row( - ProfileMembership { - user_id: "user-1".to_string(), - status: RuntimeProfileMembershipStatus::Active, - tier: RuntimeProfileMembershipTier::Month, - started_at, - expires_at, - updated_at: started_at, - cycle_started_at: Some(started_at), - cycle_resets_at: Some(current_reset_at), - cycle_granted_points: 0, - cycle_remaining_points: 0, - cycle_period_days: 90, - }, - RuntimeProfileMembershipTier::Starter, - 30, - purchased_at, - ); - - assert_eq!(row.tier, RuntimeProfileMembershipTier::Starter); - assert_eq!( - row.expires_at, - Timestamp::from_micros_since_unix_epoch(40 * PROFILE_RUNTIME_DAY_MICROS), - ); - assert_eq!(row.cycle_resets_at, Some(current_reset_at)); - assert_eq!(row.cycle_period_days, 90); - assert_eq!(row.cycle_granted_points, 0); - assert_eq!(row.cycle_remaining_points, 0); - assert_eq!(row.updated_at, purchased_at); - } - - #[test] - fn active_membership_upgrade_switches_next_cycle_period_without_moving_reset() { - let started_at = Timestamp::from_micros_since_unix_epoch(1_000); - let expires_at = Timestamp::from_micros_since_unix_epoch(90_000); - let current_reset_at = Timestamp::from_micros_since_unix_epoch(30_000); - let updated_at = Timestamp::from_micros_since_unix_epoch(2_000); - let (row, period_points_delta) = apply_active_membership_upgrade_row( - ProfileMembership { - user_id: "user-1".to_string(), - status: RuntimeProfileMembershipStatus::Active, - tier: RuntimeProfileMembershipTier::Basic, - started_at, - expires_at, - updated_at: started_at, - cycle_started_at: Some(started_at), - cycle_resets_at: Some(current_reset_at), - cycle_granted_points: 800, - cycle_remaining_points: 120, - cycle_period_days: 90, - }, - RuntimeProfileMembershipTier::Pro, - 7, - 2_500, - updated_at, - ); - - assert_eq!(row.tier, RuntimeProfileMembershipTier::Pro); - assert_eq!(row.expires_at, expires_at); - assert_eq!(row.cycle_resets_at, Some(current_reset_at)); - assert_eq!(row.cycle_period_days, 7); - assert_eq!(row.cycle_granted_points, 2_500); - assert_eq!(row.cycle_remaining_points, 1_820); - assert_eq!(row.updated_at, updated_at); - assert_eq!(period_points_delta, 1_700); - } - - #[test] - fn duplicate_tracking_event_ids_are_treated_as_idempotent_replays() { - assert!(should_skip_existing_tracking_event_id(true)); - assert!(!should_skip_existing_tracking_event_id(false)); - } - - #[test] - fn tracking_batch_result_reports_accepted_count() { - let result = RuntimeTrackingEventBatchProcedureResult { - ok: true, - accepted_count: 2, - error_message: None, - }; - - assert!(result.ok); - assert_eq!(result.accepted_count, 2); - assert!(result.error_message.is_none()); - } - - #[test] - fn invite_code_metadata_tags_are_user_tags() { - let tags = profile_invite_code_metadata_user_tags( - r#"{"tags":["系统"," beta ","系统"],"channel":"spring"}"#, - ) - .expect("metadata tags should parse"); - - assert_eq!(tags, vec!["系统".to_string(), "beta".to_string()]); - } - - #[test] - fn recent_public_work_play_counts_group_requested_profiles_in_window() { - let now_micros = PUBLIC_WORK_PLAY_DAY_MICROS * 10; - let updated_at = Timestamp::from_micros_since_unix_epoch(now_micros); - let rows = vec![ - PublicWorkPlayDailyStat { - stat_id: "puzzle:profile-a:10".to_string(), - source_type: "puzzle".to_string(), - owner_user_id: "user-a".to_string(), - profile_id: "profile-a".to_string(), - played_day: 10, - play_count: 3, - updated_at, - }, - PublicWorkPlayDailyStat { - stat_id: "puzzle:profile-a:4".to_string(), - source_type: "puzzle".to_string(), - owner_user_id: "user-a".to_string(), - profile_id: "profile-a".to_string(), - played_day: 4, - play_count: 5, - updated_at, - }, - PublicWorkPlayDailyStat { - stat_id: "puzzle:profile-a:3".to_string(), - source_type: "puzzle".to_string(), - owner_user_id: "user-a".to_string(), - profile_id: "profile-a".to_string(), - played_day: 3, - play_count: 99, - updated_at, - }, - PublicWorkPlayDailyStat { - stat_id: "custom-world:profile-a:10".to_string(), - source_type: "custom-world".to_string(), - owner_user_id: "user-a".to_string(), - profile_id: "profile-a".to_string(), - played_day: 10, - play_count: 7, - updated_at, - }, - PublicWorkPlayDailyStat { - stat_id: "puzzle:profile-b:9".to_string(), - source_type: "puzzle".to_string(), - owner_user_id: "user-b".to_string(), - profile_id: "profile-b".to_string(), - played_day: 9, - play_count: 11, - updated_at, - }, - ]; - - let counts = build_recent_public_work_play_counts( - rows, - "puzzle", - &["profile-a".to_string(), "profile-b".to_string()], - now_micros, - ); - - assert_eq!(counts.get("profile-a"), Some(&8)); - assert_eq!(counts.get("profile-b"), Some(&11)); - assert_eq!(counts.get("profile-c"), None); - } -} - -fn ensure_profile_dashboard_state(ctx: &ReducerContext, user_id: &str, updated_at: Timestamp) { - if ctx - .db - .profile_dashboard_state() - .user_id() - .find(&user_id.to_string()) - .is_some() - { - return; - } - - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: user_id.to_string(), - wallet_balance: 0, - total_play_time_ms: 0, - created_at: updated_at, - updated_at, - }); -} - -fn add_profile_dashboard_play_time( - ctx: &ReducerContext, - user_id: &str, - elapsed_ms: u64, - updated_at: Timestamp, -) { - let current = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&user_id.to_string()); - - if let Some(existing) = current { - ctx.db - .profile_dashboard_state() - .user_id() - .delete(&existing.user_id); - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: user_id.to_string(), - wallet_balance: existing.wallet_balance, - total_play_time_ms: existing.total_play_time_ms.saturating_add(elapsed_ms), - created_at: existing.created_at, - updated_at, - }); - } else { - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: user_id.to_string(), - wallet_balance: 0, - total_play_time_ms: elapsed_ms, - created_at: updated_at, - updated_at, - }); - } -} - -fn sync_profile_dashboard_from_snapshot( - ctx: &ReducerContext, - snapshot: &RuntimeSnapshot, - game_state: Option<&serde_json::Map>, - saved_at: Timestamp, -) { - refresh_profile_daily_free_points(ctx, &snapshot.user_id, ctx.timestamp); - let current_state = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&snapshot.user_id); - let previous_wallet_balance = current_state - .as_ref() - .map(|row| row.wallet_balance) - .unwrap_or(0); - let previous_total_play_time_ms = current_state - .as_ref() - .map(|row| row.total_play_time_ms) - .unwrap_or(0); - let daily_free_remaining_points = ctx - .db - .profile_daily_free_points() - .user_id() - .find(&snapshot.user_id) - .map(|row| row.remaining_points) - .unwrap_or(0); - let has_business_wallet_ledger = has_profile_business_wallet_ledger(ctx, &snapshot.user_id); - let synced_wallet_balance = if has_business_wallet_ledger { - None - } else { - game_state - .and_then(|state| state.get("playerCurrency")) - .map(|value| module_runtime::read_runtime_json_non_negative_u64(Some(value))) - .map(|legacy_balance| { - merge_legacy_wallet_balance_with_daily_free_points( - legacy_balance, - daily_free_remaining_points, - ) - }) - }; - let next_wallet_balance = synced_wallet_balance.unwrap_or(previous_wallet_balance); - let mut next_total_play_time_ms = previous_total_play_time_ms; - - if let Some(next_wallet_balance) = synced_wallet_balance - && next_wallet_balance != previous_wallet_balance - { - ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger { - wallet_ledger_id: build_runtime_profile_snapshot_wallet_ledger_id( - &snapshot.user_id, - snapshot.saved_at_micros, - next_wallet_balance, - ), - user_id: snapshot.user_id.clone(), - amount_delta: next_wallet_balance as i64 - previous_wallet_balance as i64, - balance_after: next_wallet_balance, - source_type: RuntimeProfileWalletLedgerSourceType::SnapshotSync, - created_at: saved_at, - metadata_json: Some(PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()), - }); - } - - if let Some(world_meta) = - module_runtime::resolve_runtime_profile_world_snapshot_meta(game_state) - { - let current_play_time_ms = module_runtime::read_runtime_json_non_negative_u64( - game_state - .and_then(|state| state.get("runtimeStats")) - .and_then(JsonValue::as_object) - .and_then(|stats| stats.get("playTimeMs")), - ); - let played_world_id = - build_runtime_profile_played_world_id(&snapshot.user_id, &world_meta.world_key); - let existing = ctx - .db - .profile_played_world() - .played_world_id() - .find(&played_world_id); - let previous_observed_play_time_ms = existing - .as_ref() - .map(|row| row.last_observed_play_time_ms) - .unwrap_or(0); - let incremental_play_time_ms = - current_play_time_ms.saturating_sub(previous_observed_play_time_ms); - next_total_play_time_ms = next_total_play_time_ms.saturating_add(incremental_play_time_ms); - - if let Some(existing) = existing { - ctx.db - .profile_played_world() - .played_world_id() - .delete(&existing.played_world_id); - ctx.db.profile_played_world().insert(ProfilePlayedWorld { - played_world_id, - user_id: snapshot.user_id.clone(), - world_key: world_meta.world_key, - owner_user_id: world_meta.owner_user_id, - profile_id: world_meta.profile_id, - world_type: world_meta.world_type, - world_title: world_meta.world_title, - world_subtitle: world_meta.world_subtitle, - first_played_at: existing.first_played_at, - last_played_at: saved_at, - last_observed_play_time_ms: current_play_time_ms - .max(existing.last_observed_play_time_ms), - }); - } else { - ctx.db.profile_played_world().insert(ProfilePlayedWorld { - played_world_id, - user_id: snapshot.user_id.clone(), - world_key: world_meta.world_key, - owner_user_id: world_meta.owner_user_id, - profile_id: world_meta.profile_id, - world_type: world_meta.world_type, - world_title: world_meta.world_title, - world_subtitle: world_meta.world_subtitle, - first_played_at: saved_at, - last_played_at: saved_at, - last_observed_play_time_ms: current_play_time_ms, - }); - } - } - - if let Some(existing) = current_state { - ctx.db - .profile_dashboard_state() - .user_id() - .delete(&existing.user_id); - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: snapshot.user_id.clone(), - wallet_balance: next_wallet_balance, - total_play_time_ms: next_total_play_time_ms, - created_at: existing.created_at, - updated_at: saved_at, - }); - } else { - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: snapshot.user_id.clone(), - wallet_balance: next_wallet_balance, - total_play_time_ms: next_total_play_time_ms, - created_at: saved_at, - updated_at: saved_at, - }); - } -} - -fn sync_profile_save_archive_from_snapshot( - ctx: &ReducerContext, - snapshot: &RuntimeSnapshot, - game_state: &JsonValue, - saved_at: Timestamp, -) -> Result<(), String> { - let Some(archive_meta) = module_runtime::resolve_runtime_profile_save_archive_meta( - game_state, - snapshot.current_story_json.as_deref(), - ) else { - return Ok(()); - }; - - let archive_id = - build_runtime_profile_save_archive_id(&snapshot.user_id, &archive_meta.world_key); - let existing = ctx.db.profile_save_archive().archive_id().find(&archive_id); - let created_at = existing - .as_ref() - .map(|row| row.created_at) - .unwrap_or(saved_at); - - if let Some(existing) = existing { - ctx.db - .profile_save_archive() - .archive_id() - .delete(&existing.archive_id); - } - - ctx.db.profile_save_archive().insert(ProfileSaveArchive { - archive_id, - user_id: snapshot.user_id.clone(), - world_key: archive_meta.world_key, - owner_user_id: archive_meta.owner_user_id, - profile_id: archive_meta.profile_id, - world_type: archive_meta.world_type, - world_name: archive_meta.world_name, - subtitle: archive_meta.subtitle, - summary_text: archive_meta.summary_text, - cover_image_src: archive_meta.cover_image_src, - saved_at, - bottom_tab: snapshot.bottom_tab.clone(), - game_state_json: snapshot.game_state_json.clone(), - current_story_json: snapshot.current_story_json.clone(), - created_at, - updated_at: saved_at, - }); - - Ok(()) -} - -pub(crate) fn build_profile_save_archive_snapshot_from_row( - row: &ProfileSaveArchive, -) -> RuntimeProfileSaveArchiveSnapshot { - RuntimeProfileSaveArchiveSnapshot { - archive_id: row.archive_id.clone(), - user_id: row.user_id.clone(), - world_key: row.world_key.clone(), - owner_user_id: row.owner_user_id.clone(), - profile_id: row.profile_id.clone(), - world_type: row.world_type.clone(), - world_name: row.world_name.clone(), - subtitle: row.subtitle.clone(), - summary_text: row.summary_text.clone(), - cover_image_src: row.cover_image_src.clone(), - saved_at_micros: row.saved_at.to_micros_since_unix_epoch(), - bottom_tab: row.bottom_tab.clone(), - game_state_json: row.game_state_json.clone(), - current_story_json: row.current_story_json.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn get_profile_dashboard_snapshot( - ctx: &ReducerContext, - input: RuntimeProfileDashboardGetInput, -) -> Result { - let validated_input = build_runtime_profile_dashboard_get_input(input.user_id) - .map_err(|error| error.to_string())?; - refresh_profile_wallet_expiring_points(ctx, &validated_input.user_id, ctx.timestamp); - let state = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&validated_input.user_id); - let played_world_count = ctx - .db - .profile_played_world() - .by_profile_played_world_user_id() - .filter(&validated_input.user_id) - .count() as u32; - let daily_free_points = - build_profile_daily_free_points_snapshot(ctx, &validated_input.user_id, ctx.timestamp); - - Ok(match state { - Some(existing) => RuntimeProfileDashboardSnapshot { - user_id: existing.user_id, - wallet_balance: existing.wallet_balance, - total_play_time_ms: existing.total_play_time_ms, - played_world_count, - updated_at_micros: Some(existing.updated_at.to_micros_since_unix_epoch()), - daily_free_points, - }, - None => RuntimeProfileDashboardSnapshot { - user_id: validated_input.user_id, - wallet_balance: 0, - total_play_time_ms: 0, - played_world_count, - updated_at_micros: None, - daily_free_points, - }, - }) -} - -fn list_profile_wallet_ledger_entries( - ctx: &ReducerContext, - input: RuntimeProfileWalletLedgerListInput, -) -> Result, String> { - let validated_input = build_runtime_profile_wallet_ledger_list_input(input.user_id) - .map_err(|error| error.to_string())?; - refresh_profile_wallet_expiring_points(ctx, &validated_input.user_id, ctx.timestamp); - - let mut entries = ctx - .db - .profile_wallet_ledger() - .by_profile_wallet_ledger_user_id() - .filter(&validated_input.user_id) - .map(|row| build_profile_wallet_ledger_snapshot_from_row(&row)) - .collect::>(); - - let current_balance = profile_wallet_balance(ctx, &validated_input.user_id); - sort_profile_wallet_ledger_entries(&mut entries, current_balance); - entries.truncate(PROFILE_WALLET_LEDGER_LIST_LIMIT); - - Ok(entries) -} - -fn sort_profile_wallet_ledger_entries( - entries: &mut Vec, - current_balance: u64, -) { - entries.sort_by(|left, right| { - right - .created_at_micros - .cmp(&left.created_at_micros) - .then_with(|| left.wallet_ledger_id.cmp(&right.wallet_ledger_id)) - }); - - let mut positions_by_balance = HashMap::>::new(); - for (position, entry) in entries.iter().enumerate() { - positions_by_balance - .entry(entry.balance_after) - .or_default() - .push_back(position); - } - - let mut remaining = std::mem::take(entries) - .into_iter() - .map(Some) - .collect::>(); - let mut ordered = Vec::with_capacity(remaining.len()); - let mut expected_balance = current_balance; - - while ordered.len() < remaining.len() { - let Some(position) = positions_by_balance - .get_mut(&expected_balance) - .and_then(VecDeque::pop_front) - else { - break; - }; - let Some(entry) = remaining[position].take() else { - break; - }; - let previous_balance = i128::from(entry.balance_after) - i128::from(entry.amount_delta); - let Ok(previous_balance) = u64::try_from(previous_balance) else { - ordered.push(entry); - break; - }; - ordered.push(entry); - expected_balance = previous_balance; - } - - ordered.extend(remaining.into_iter().flatten()); - *entries = ordered; -} - -fn get_profile_play_stats_snapshot( - ctx: &ReducerContext, - input: RuntimeProfilePlayStatsGetInput, -) -> Result { - let validated_input = build_runtime_profile_play_stats_get_input(input.user_id) - .map_err(|error| error.to_string())?; - let dashboard_state = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&validated_input.user_id); - let mut played_works = ctx - .db - .profile_played_world() - .by_profile_played_world_user_id() - .filter(&validated_input.user_id) - .map(|row| build_profile_played_world_snapshot_from_row(&row)) - .collect::>(); - - played_works.sort_by(|left, right| { - right - .last_played_at_micros - .cmp(&left.last_played_at_micros) - .then_with(|| left.played_world_id.cmp(&right.played_world_id)) - }); - - Ok(RuntimeProfilePlayStatsSnapshot { - user_id: validated_input.user_id, - total_play_time_ms: dashboard_state - .as_ref() - .map(|row| row.total_play_time_ms) - .unwrap_or(0), - played_works, - updated_at_micros: dashboard_state - .as_ref() - .map(|row| row.updated_at.to_micros_since_unix_epoch()), - }) -} - -fn get_profile_recharge_center_snapshot( - ctx: &ReducerContext, - input: RuntimeProfileRechargeCenterGetInput, -) -> Result { - let validated_input = build_runtime_profile_recharge_center_get_input(input.user_id) - .map_err(|error| error.to_string())?; - Ok(build_profile_recharge_center_snapshot( - ctx, - &validated_input.user_id, - )) -} - -fn create_profile_recharge_order_record( - ctx: &ReducerContext, - input: RuntimeProfileRechargeOrderCreateInput, -) -> Result< - ( - RuntimeProfileRechargeCenterSnapshot, - RuntimeProfileRechargeOrderSnapshot, - ), - String, -> { - let validated_input = build_runtime_profile_recharge_order_create_input( - input.user_id, - input.product_id, - input.payment_channel, - input.created_at_micros, - ) - .map_err(|error| error.to_string())?; - let product = enabled_profile_recharge_product_by_id(ctx, &validated_input.product_id) - .ok_or_else(|| "recharge.product_id 不存在或已下架".to_string())?; - let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros); - let should_settle_immediately = - validated_input.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK; - let amount_cents = resolve_profile_recharge_order_amount_cents( - ctx, - &validated_input.user_id, - &product, - created_at, - )?; - let (status, paid_at, points_delta, membership_expires_at) = if should_settle_immediately { - let (points_delta, membership_expires_at) = apply_profile_recharge_purchase( - ctx, - &validated_input.user_id, - &product, - validated_input.created_at_micros, - created_at, - )?; - ( - RuntimeProfileRechargeOrderStatus::Paid, - Some(created_at), - points_delta, - membership_expires_at, - ) - } else { - (RuntimeProfileRechargeOrderStatus::Pending, None, 0, None) - }; - - let order = ProfileRechargeOrder { - order_id: build_runtime_profile_recharge_order_id( - &validated_input.user_id, - validated_input.created_at_micros, - &product.product_id, - ), - user_id: validated_input.user_id.clone(), - product_id: product.product_id.clone(), - product_title: product.title.clone(), - kind: product.kind, - amount_cents, - status, - payment_channel: validated_input.payment_channel, - paid_at, - provider_transaction_id: None, - created_at, - points_delta, - membership_expires_at, - expired_at: None, - expiration_checked_at: None, - expiration_provider_state: None, - expiration_last_error: None, - }; - ctx.db.profile_recharge_order().insert(order.clone()); - if order.status == RuntimeProfileRechargeOrderStatus::Pending - && should_schedule_profile_recharge_order_expiration(&order.payment_channel) - { - let scheduled_at = created_at - + std::time::Duration::from_secs(PROFILE_RECHARGE_ORDER_EXPIRATION_SECONDS as u64); - ctx.db.profile_recharge_order_expiration_timer().insert( - ProfileRechargeOrderExpirationTimer { - scheduled_id: 0, - order_id: order.order_id.clone(), - user_id: order.user_id.clone(), - scheduled_at: scheduled_at.into(), - created_at, - }, - ); - } - - let latest_order = latest_profile_recharge_order(ctx, &validated_input.user_id) - .ok_or_else(|| "profile_recharge_order 写入后未能读取".to_string())?; - Ok(( - build_profile_recharge_center_snapshot(ctx, &validated_input.user_id), - build_profile_recharge_order_snapshot_from_row(&latest_order), - )) -} - -fn get_profile_recharge_order_snapshot( - ctx: &ReducerContext, - input: RuntimeProfileRechargeOrderGetInput, -) -> Result< - ( - RuntimeProfileRechargeCenterSnapshot, - RuntimeProfileRechargeOrderSnapshot, - ), - String, -> { - let validated_input = build_runtime_profile_recharge_order_get_input(input.order_id) - .map_err(|error| error.to_string())?; - let order = ctx - .db - .profile_recharge_order() - .order_id() - .find(&validated_input.order_id) - .ok_or_else(|| "profile_recharge_order missing".to_string())?; - - Ok(( - build_profile_recharge_center_snapshot(ctx, &order.user_id), - build_profile_recharge_order_snapshot_from_row(&order), - )) -} - -fn mark_profile_recharge_order_paid_record( - ctx: &ReducerContext, - input: RuntimeProfileRechargeOrderPaidInput, -) -> Result< - ( - RuntimeProfileRechargeCenterSnapshot, - RuntimeProfileRechargeOrderSnapshot, - ), - String, -> { - let validated_input = build_runtime_profile_recharge_order_paid_input( - input.order_id, - input.paid_at_micros, - input.provider_transaction_id, - ) - .map_err(|error| error.to_string())?; - let mut order = ctx - .db - .profile_recharge_order() - .order_id() - .find(&validated_input.order_id) - .ok_or_else(|| "profile_recharge_order 不存在".to_string())?; - - if matches!( - order.status, - RuntimeProfileRechargeOrderStatus::Paid | RuntimeProfileRechargeOrderStatus::Refunded - ) { - validate_profile_recharge_order_paid_replay_transaction_id( - &order, - &validated_input.provider_transaction_id, - )?; - delete_profile_recharge_order_expiration_task(ctx, &order.order_id); - return Ok(( - build_profile_recharge_center_snapshot(ctx, &order.user_id), - build_profile_recharge_order_snapshot_from_row(&order), - )); - } - if !matches!( - order.status, - RuntimeProfileRechargeOrderStatus::Pending | RuntimeProfileRechargeOrderStatus::Expired - ) { - return Err("profile_recharge_order 当前状态不能确认支付".to_string()); - } - - let product = profile_recharge_product_by_id(ctx, &order.product_id) - .ok_or_else(|| "recharge.product_id 不存在".to_string())?; - let paid_at = Timestamp::from_micros_since_unix_epoch(validated_input.paid_at_micros); - let (points_delta, membership_expires_at) = apply_profile_recharge_purchase( - ctx, - &order.user_id, - &product, - order.created_at.to_micros_since_unix_epoch(), - paid_at, - )?; - - ctx.db - .profile_recharge_order() - .order_id() - .delete(&order.order_id); - order.status = RuntimeProfileRechargeOrderStatus::Paid; - order.paid_at = Some(paid_at); - order.provider_transaction_id = validated_input.provider_transaction_id; - order.points_delta = points_delta; - order.membership_expires_at = membership_expires_at; - ctx.db.profile_recharge_order().insert(order.clone()); - delete_profile_recharge_order_expiration_task(ctx, &order.order_id); - - Ok(( - build_profile_recharge_center_snapshot(ctx, &order.user_id), - build_profile_recharge_order_snapshot_from_row(&order), - )) -} - -fn validate_profile_recharge_order_paid_replay_transaction_id( - order: &ProfileRechargeOrder, - provider_transaction_id: &Option, -) -> Result<(), String> { - if &order.provider_transaction_id != provider_transaction_id { - return Err("profile_recharge_order provider_transaction_id 重放不匹配".to_string()); - } - Ok(()) -} - -fn admin_list_profile_recharge_order_entries( - ctx: &ReducerContext, - input: RuntimeProfileRechargeOrderAdminListInput, -) -> Result, String> { - let validated = build_runtime_profile_recharge_order_admin_list_input( - input.order_id, - input.user_id, - input.provider_transaction_id, - input.payment_channel, - input.status, - input.created_after_micros, - input.created_before_micros, - input.limit, - )?; - let mut rows = ctx - .db - .profile_recharge_order() - .iter() - .filter(|row| profile_recharge_order_matches_admin_query(row, &validated)) - .collect::>(); - rows.sort_by(|left, right| { - right - .created_at - .to_micros_since_unix_epoch() - .cmp(&left.created_at.to_micros_since_unix_epoch()) - .then_with(|| right.order_id.cmp(&left.order_id)) - }); - Ok(rows - .into_iter() - .take(validated.limit as usize) - .map(|row| build_profile_recharge_order_admin_entry_snapshot(ctx, &row)) - .collect()) -} - -fn profile_recharge_order_matches_admin_query( - row: &ProfileRechargeOrder, - query: &RuntimeProfileRechargeOrderAdminListInput, -) -> bool { - query - .order_id - .as_deref() - .is_none_or(|value| row.order_id == value) - && query - .user_id - .as_deref() - .is_none_or(|value| row.user_id == value) - && query - .provider_transaction_id - .as_deref() - .is_none_or(|value| row.provider_transaction_id.as_deref() == Some(value)) - && query - .payment_channel - .as_deref() - .is_none_or(|value| row.payment_channel == value) - && query.status.is_none_or(|value| row.status == value) - && query - .created_after_micros - .is_none_or(|value| row.created_at.to_micros_since_unix_epoch() >= value) - && query - .created_before_micros - .is_none_or(|value| row.created_at.to_micros_since_unix_epoch() <= value) -} - -fn build_profile_recharge_order_admin_entry_snapshot( - ctx: &ReducerContext, - row: &ProfileRechargeOrder, -) -> RuntimeProfileRechargeOrderAdminEntrySnapshot { - let settlement = ctx - .db - .profile_recharge_order_refund_settlement() - .order_id() - .find(&row.order_id) - .map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(&value)); - let mut refunds = ctx - .db - .profile_recharge_refund() - .by_profile_recharge_refund_order_id() - .filter(&row.order_id) - .map(|value| build_profile_recharge_refund_snapshot_from_row(&value)) - .collect::>(); - refunds.sort_by(|left, right| { - right - .first_observed_at_micros - .cmp(&left.first_observed_at_micros) - .then_with(|| left.out_refund_no.cmp(&right.out_refund_no)) - }); - RuntimeProfileRechargeOrderAdminEntrySnapshot { - order: build_profile_recharge_order_snapshot_from_row(row), - settlement, - refunds, - active_hold: active_profile_recharge_refund_hold_for_order(ctx, &row.order_id) - .map(|value| build_profile_recharge_refund_hold_snapshot_from_row(&value)), - wallet: build_profile_admin_wallet_snapshot(ctx, &row.user_id), - } -} - -fn preview_profile_recharge_refund_hold( - ctx: &ReducerContext, - input: RuntimeProfileRechargeRefundHoldPreviewInput, -) -> Result< - ( - RuntimeProfileRechargeRefundHoldSnapshot, - RuntimeProfileRechargeOrderSnapshot, - Option, - RuntimeProfileAdminWalletSnapshot, - ), - String, -> { - let validated = build_runtime_profile_recharge_refund_hold_preview_input( - input.order_id, - input.refund_cents, - )?; - let (order, settlement, held_points) = validate_profile_recharge_refund_hold_eligibility( - ctx, - &validated.order_id, - validated.refund_cents, - )?; - let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); - Ok(( - RuntimeProfileRechargeRefundHoldSnapshot { - out_refund_no: String::new(), - order_id: order.order_id.clone(), - user_id: order.user_id.clone(), - refund_cents: validated.refund_cents, - held_points, - status: RuntimeProfileRechargeRefundHoldStatus::Active, - admin_user_id: String::new(), - reason: String::new(), - created_at_micros: now_micros, - updated_at_micros: now_micros, - settled_at_micros: None, - released_at_micros: None, - released_by_admin_user_id: None, - release_reason: None, - }, - build_profile_recharge_order_snapshot_from_row(&order), - settlement - .as_ref() - .map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(value)), - build_profile_admin_wallet_snapshot(ctx, &order.user_id), - )) -} - -fn prepare_profile_recharge_refund_hold( - ctx: &ReducerContext, - input: RuntimeProfileRechargeRefundHoldPrepareInput, -) -> Result< - ( - RuntimeProfileRechargeRefundHoldSnapshot, - RuntimeProfileRechargeOrderSnapshot, - Option, - RuntimeProfileAdminWalletSnapshot, - ), - String, -> { - let validated = build_runtime_profile_recharge_refund_hold_prepare_input( - input.order_id, - input.out_refund_no, - input.refund_cents, - input.admin_user_id, - input.reason, - )?; - if let Some(existing) = ctx - .db - .profile_recharge_refund_hold() - .out_refund_no() - .find(&validated.out_refund_no) - { - if existing.order_id != validated.order_id - || existing.refund_cents != validated.refund_cents - || existing.admin_user_id != validated.admin_user_id - || existing.reason != validated.reason - { - return Err("refund_hold 幂等重放内容冲突".to_string()); - } - let order = ctx - .db - .profile_recharge_order() - .order_id() - .find(&existing.order_id) - .ok_or_else(|| "refund_hold 对应充值订单不存在".to_string())?; - let settlement = ctx - .db - .profile_recharge_order_refund_settlement() - .order_id() - .find(&order.order_id) - .map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(&value)); - return Ok(( - build_profile_recharge_refund_hold_snapshot_from_row(&existing), - build_profile_recharge_order_snapshot_from_row(&order), - settlement, - build_profile_admin_wallet_snapshot(ctx, &order.user_id), - )); - } - if ctx - .db - .profile_recharge_refund() - .out_refund_no() - .find(&validated.out_refund_no) - .is_some() - { - return Err("refund_hold 对应退款单已经存在".to_string()); - } - let (order, settlement, held_points) = validate_profile_recharge_refund_hold_eligibility( - ctx, - &validated.order_id, - validated.refund_cents, - )?; - let row = ProfileRechargeRefundHold { - out_refund_no: validated.out_refund_no, - order_id: order.order_id.clone(), - user_id: order.user_id.clone(), - refund_cents: validated.refund_cents, - held_points, - status: RuntimeProfileRechargeRefundHoldStatus::Active, - admin_user_id: validated.admin_user_id, - reason: validated.reason, - created_at: ctx.timestamp, - updated_at: ctx.timestamp, - settled_at: None, - released_at: None, - released_by_admin_user_id: None, - release_reason: None, - }; - ctx.db.profile_recharge_refund_hold().insert(row.clone()); - Ok(( - build_profile_recharge_refund_hold_snapshot_from_row(&row), - build_profile_recharge_order_snapshot_from_row(&order), - settlement - .as_ref() - .map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(value)), - build_profile_admin_wallet_snapshot(ctx, &order.user_id), - )) -} - -fn validate_profile_recharge_refund_hold_eligibility( - ctx: &ReducerContext, - order_id: &str, - refund_cents: u64, -) -> Result< - ( - ProfileRechargeOrder, - Option, - u64, - ), - String, -> { - let order = ctx - .db - .profile_recharge_order() - .order_id() - .find(&order_id.to_string()) - .ok_or_else(|| "充值订单不存在".to_string())?; - if order.kind != RuntimeProfileRechargeProductKind::Points { - return Err("首期后台退款只支持泥点充值订单".to_string()); - } - if order.status != RuntimeProfileRechargeOrderStatus::Paid || order.paid_at.is_none() { - return Err("充值订单不是可退款的已支付状态".to_string()); - } - if !matches!( - order.payment_channel.as_str(), - PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM - | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI - | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 - | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE - ) { - return Err("首期后台退款只支持普通微信 V3 订单".to_string()); - } - if order.provider_transaction_id.is_none() || order.points_delta <= 0 { - return Err("充值订单缺少可退款的支付或泥点结算事实".to_string()); - } - if active_profile_recharge_refund_hold_for_order(ctx, &order.order_id).is_some() { - return Err("充值订单已有退款占用,需先完成对账".to_string()); - } - if ctx - .db - .profile_recharge_refund() - .by_profile_recharge_refund_order_id() - .filter(&order.order_id) - .any(|row| { - matches!( - row.provider_status, - RuntimeProfileRechargeRefundStatus::Processing - | RuntimeProfileRechargeRefundStatus::Abnormal - ) - }) - { - return Err("充值订单存在未完成退款,需先完成对账".to_string()); - } - let settlement = ctx - .db - .profile_recharge_order_refund_settlement() - .order_id() - .find(&order.order_id); - if settlement - .as_ref() - .is_some_and(|value| value.unrecovered_points > 0) - { - return Err("账户存在退款异常欠账,不能继续发起退款".to_string()); - } - if has_profile_wallet_manual_restriction(ctx, &order.user_id) { - return Err("账户已被人工冻结,不能继续发起退款".to_string()); - } - let plan = build_runtime_profile_recharge_refund_settlement_plan( - settlement - .as_ref() - .map(|value| value.successful_refund_count) - .unwrap_or(0), - settlement - .as_ref() - .map(|value| value.cumulative_success_refund_cents) - .unwrap_or(0), - settlement - .as_ref() - .map(|value| value.target_recovery_points) - .unwrap_or(0), - refund_cents, - order.amount_cents, - order.points_delta, - )?; - let remaining_refundable_cents = order.amount_cents.saturating_sub( - settlement - .as_ref() - .map(|value| value.cumulative_success_refund_cents) - .unwrap_or(0), - ); - let held_points = resolve_runtime_profile_recharge_refund_hold_points( - plan.incremental_target_recovery_points, - refund_cents, - remaining_refundable_cents, - ); - let wallet = build_profile_admin_wallet_snapshot(ctx, &order.user_id); - validate_runtime_profile_recharge_refund_hold_capacity( - held_points, - wallet.permanent_points, - wallet.held_points, - )?; - Ok((order, settlement, held_points)) -} - -fn release_profile_recharge_refund_hold( - ctx: &ReducerContext, - input: RuntimeProfileRechargeRefundHoldReleaseInput, -) -> Result< - ( - RuntimeProfileRechargeRefundHoldSnapshot, - RuntimeProfileRechargeOrderSnapshot, - Option, - RuntimeProfileAdminWalletSnapshot, - ), - String, -> { - let validated = build_runtime_profile_recharge_refund_hold_release_input( - input.out_refund_no, - input.admin_user_id, - input.release_reason, - )?; - let mut row = ctx - .db - .profile_recharge_refund_hold() - .out_refund_no() - .find(&validated.out_refund_no) - .ok_or_else(|| "refund_hold 不存在".to_string())?; - if row.status == RuntimeProfileRechargeRefundHoldStatus::Settled { - return Err("已结算退款占用不能释放".to_string()); - } - if row.status == RuntimeProfileRechargeRefundHoldStatus::Active { - row.status = RuntimeProfileRechargeRefundHoldStatus::Released; - row.updated_at = ctx.timestamp; - row.released_at = Some(ctx.timestamp); - row.released_by_admin_user_id = Some(validated.admin_user_id); - row.release_reason = Some(validated.release_reason); - upsert_profile_recharge_refund_hold_row(ctx, row.clone()); - } - let order = ctx - .db - .profile_recharge_order() - .order_id() - .find(&row.order_id) - .ok_or_else(|| "refund_hold 对应充值订单不存在".to_string())?; - let settlement = ctx - .db - .profile_recharge_order_refund_settlement() - .order_id() - .find(&order.order_id) - .map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(&value)); - Ok(( - build_profile_recharge_refund_hold_snapshot_from_row(&row), - build_profile_recharge_order_snapshot_from_row(&order), - settlement, - build_profile_admin_wallet_snapshot(ctx, &order.user_id), - )) -} - -fn resolve_profile_recharge_refund_manual_review( - ctx: &ReducerContext, - input: RuntimeProfileRechargeRefundManualReviewResolveInput, -) -> Result< - ( - RuntimeProfileRechargeRefundSnapshot, - Option, - bool, - String, - ), - String, -> { - let validated = build_runtime_profile_recharge_refund_manual_review_resolve_input( - input.out_refund_no, - input.admin_user_id, - input.reason, - input.expected_error_code, - )?; - let mut refund = ctx - .db - .profile_recharge_refund() - .out_refund_no() - .find(&validated.out_refund_no) - .ok_or_else(|| "退款记录不存在".to_string())?; - validate_profile_recharge_refund_manual_review_request( - &refund, - &validated.expected_error_code, - &validated.admin_user_id, - &validated.reason, - )?; - if refund.manual_review_resolved_at.is_some() { - let settlement = ctx - .db - .profile_recharge_order_refund_settlement() - .order_id() - .find(&refund.order_id) - .map(|row| build_profile_recharge_order_refund_settlement_snapshot_from_row(&row)); - return Ok(( - build_profile_recharge_refund_snapshot_from_row(&refund), - settlement, - true, - "manual_review_already_resolved".to_string(), - )); - } - if refund.provider_status != RuntimeProfileRechargeRefundStatus::Success - || refund.recovery_status != RuntimeProfileRechargeRefundRecoveryStatus::ManualReview - { - return Err("只有已成功且待人工复核的退款可以确认归属".to_string()); - } - let review_code = refund - .last_error_code - .clone() - .ok_or_else(|| "退款缺少人工复核原因".to_string())?; - if !matches!( - review_code.as_str(), - "provider_transaction_id_mismatch" | "order_total_mismatch" - ) { - return Err(format!( - "当前人工复核原因不能通过确认订单归属收口: {review_code}" - )); - } - let order = ctx - .db - .profile_recharge_order() - .order_id() - .find(&refund.order_id) - .ok_or_else(|| "退款对应充值订单不存在".to_string())?; - if order.kind != RuntimeProfileRechargeProductKind::Points { - return Err("会员退款不能使用泥点钱包人工复核入口".to_string()); - } - if let Some(remaining_code) = profile_recharge_refund_order_match_error_except( - &order, - &refund, - Some(review_code.as_str()), - ) { - return Err(format!( - "确认当前退款冲突后仍存在未获批准的订单不匹配: {remaining_code}" - )); - } - - refund.manual_review_resolved_by_admin_user_id = Some(validated.admin_user_id); - refund.manual_review_resolution_reason = Some(validated.reason); - refund.manual_review_resolved_at = Some(ctx.timestamp); - refund.manual_review_resolved_error_code = Some(review_code); - refund.updated_at = ctx.timestamp; - let (settlement, resolution_code) = reconcile_profile_recharge_refund_success(ctx, &mut refund); - if refund.recovery_status == RuntimeProfileRechargeRefundRecoveryStatus::ManualReview { - return Err(format!( - "确认订单归属后权益结算仍需人工复核: {}", - refund - .last_error_code - .as_deref() - .unwrap_or(resolution_code.as_str()) - )); - } - upsert_profile_recharge_refund_row(ctx, refund.clone()); - Ok(( - build_profile_recharge_refund_snapshot_from_row(&refund), - settlement - .map(|row| build_profile_recharge_order_refund_settlement_snapshot_from_row(&row)), - false, - format!("manual_review_resolved:{resolution_code}"), - )) -} - -fn validate_profile_recharge_refund_manual_review_request( - refund: &ProfileRechargeRefund, - expected_error_code: &str, - admin_user_id: &str, - reason: &str, -) -> Result<(), String> { - let current_error_code = if refund.manual_review_resolved_at.is_some() { - refund.manual_review_resolved_error_code.as_deref() - } else { - refund.last_error_code.as_deref() - }; - if current_error_code != Some(expected_error_code) { - return Err(format!( - "退款人工复核状态已变化,当前错误码为 {}", - current_error_code.unwrap_or("-") - )); - } - if refund.manual_review_resolved_at.is_some() - && (refund.manual_review_resolved_by_admin_user_id.as_deref() != Some(admin_user_id) - || refund.manual_review_resolution_reason.as_deref() != Some(reason)) - { - return Err("退款人工复核已由其他审计内容处理".to_string()); - } - Ok(()) -} - -fn build_profile_admin_wallet_snapshot( - ctx: &ReducerContext, - user_id: &str, -) -> RuntimeProfileAdminWalletSnapshot { - refresh_profile_wallet_expiring_points(ctx, user_id, ctx.timestamp); - let total_balance = profile_wallet_balance(ctx, user_id); - let daily_free_points = ctx - .db - .profile_daily_free_points() - .user_id() - .find(&user_id.to_string()) - .map(|row| row.remaining_points) - .unwrap_or(0); - let membership_limited_points = ctx - .db - .profile_membership() - .user_id() - .find(&user_id.to_string()) - .filter(|row| active_membership_row_at(row, ctx.timestamp)) - .map(|row| row.cycle_remaining_points) - .unwrap_or(0); - let permanent_points = total_balance - .saturating_sub(daily_free_points) - .saturating_sub(membership_limited_points); - let held_points = active_profile_recharge_refund_hold_points(ctx, user_id); - let (refund_debt_points, refund_debt_frozen) = ctx - .db - .profile_recharge_order_refund_settlement() - .by_profile_recharge_order_refund_settlement_user_id() - .filter(user_id) - .fold((0_u64, false), |(total, frozen), row| { - ( - total.saturating_add(row.unrecovered_points), - frozen || profile_recharge_refund_settlement_freezes_wallet(&row), - ) - }); - let manual_restriction = ctx - .db - .profile_wallet_manual_restriction() - .user_id() - .find(&user_id.to_string()); - let manual_frozen = manual_restriction.as_ref().is_some_and(|row| row.frozen); - let wallet_frozen = manual_frozen || refund_debt_frozen; - RuntimeProfileAdminWalletSnapshot { - user_id: user_id.to_string(), - total_balance, - spendable_balance: if wallet_frozen { - 0 - } else { - total_balance.saturating_sub(held_points) - }, - daily_free_points, - membership_limited_points, - permanent_points, - held_points, - refund_debt_points, - manual_frozen, - refund_debt_frozen, - wallet_frozen, - manual_restriction: manual_restriction - .as_ref() - .map(build_profile_wallet_manual_restriction_snapshot_from_row), - } -} - -fn upsert_profile_wallet_manual_restriction( - ctx: &ReducerContext, - input: RuntimeProfileWalletManualRestrictionUpsertInput, -) { - let existing = ctx - .db - .profile_wallet_manual_restriction() - .user_id() - .find(&input.user_id); - let row = ProfileWalletManualRestriction { - user_id: input.user_id, - frozen: input.frozen, - reason: input.reason, - created_by_admin_user_id: existing - .as_ref() - .map(|value| value.created_by_admin_user_id.clone()) - .unwrap_or_else(|| input.admin_user_id.clone()), - created_at: existing - .as_ref() - .map(|value| value.created_at) - .unwrap_or(ctx.timestamp), - updated_by_admin_user_id: input.admin_user_id, - updated_at: ctx.timestamp, - }; - ctx.db - .profile_wallet_manual_restriction() - .user_id() - .delete(&row.user_id); - ctx.db.profile_wallet_manual_restriction().insert(row); -} - -fn active_profile_recharge_refund_hold_for_order( - ctx: &ReducerContext, - order_id: &str, -) -> Option { - ctx.db - .profile_recharge_refund_hold() - .by_profile_recharge_refund_hold_order_id() - .filter(order_id) - .find(|row| row.status == RuntimeProfileRechargeRefundHoldStatus::Active) -} - -fn active_profile_recharge_refund_hold_points(ctx: &ReducerContext, user_id: &str) -> u64 { - ctx.db - .profile_recharge_refund_hold() - .by_profile_recharge_refund_hold_user_id() - .filter(user_id) - .filter(|row| row.status == RuntimeProfileRechargeRefundHoldStatus::Active) - .fold(0_u64, |total, row| total.saturating_add(row.held_points)) -} - -fn active_profile_recharge_refund_unrelated_hold_points( - ctx: &ReducerContext, - user_id: &str, - refund: &ProfileRechargeRefund, -) -> u64 { - ctx.db - .profile_recharge_refund_hold() - .by_profile_recharge_refund_hold_user_id() - .filter(user_id) - .filter(|row| row.status == RuntimeProfileRechargeRefundHoldStatus::Active) - .filter(|row| { - row.out_refund_no != refund.out_refund_no - || row.order_id != refund.order_id - || row.refund_cents != refund.refund_cents - }) - .fold(0_u64, |total, row| total.saturating_add(row.held_points)) -} - -fn has_profile_wallet_manual_restriction(ctx: &ReducerContext, user_id: &str) -> bool { - ctx.db - .profile_wallet_manual_restriction() - .user_id() - .find(&user_id.to_string()) - .is_some_and(|row| row.frozen) -} - -fn upsert_profile_recharge_refund_hold_row(ctx: &ReducerContext, row: ProfileRechargeRefundHold) { - ctx.db - .profile_recharge_refund_hold() - .out_refund_no() - .delete(&row.out_refund_no); - ctx.db.profile_recharge_refund_hold().insert(row); -} - -fn sync_profile_recharge_refund_hold_with_provider_status( - ctx: &ReducerContext, - refund: &ProfileRechargeRefund, -) { - let Some(mut hold) = ctx - .db - .profile_recharge_refund_hold() - .out_refund_no() - .find(&refund.out_refund_no) - else { - return; - }; - if hold.order_id != refund.order_id || hold.refund_cents != refund.refund_cents { - return; - } - - let next_status = - resolve_runtime_profile_recharge_refund_hold_status(hold.status, refund.provider_status); - if next_status == hold.status { - return; - } - hold.status = next_status; - hold.updated_at = ctx.timestamp; - match next_status { - RuntimeProfileRechargeRefundHoldStatus::Settled => { - hold.settled_at = Some(ctx.timestamp); - } - RuntimeProfileRechargeRefundHoldStatus::Released => { - hold.released_at = Some(ctx.timestamp); - hold.released_by_admin_user_id = None; - hold.release_reason = Some("provider_closed".to_string()); - } - RuntimeProfileRechargeRefundHoldStatus::Active => {} - } - upsert_profile_recharge_refund_hold_row(ctx, hold); -} - -fn record_profile_recharge_refund_observation( - ctx: &ReducerContext, - input: RuntimeProfileRechargeRefundObservationInput, -) -> Result< - ( - RuntimeProfileRechargeRefundSnapshot, - Option, - bool, - String, - ), - String, -> { - let validated = build_runtime_profile_recharge_refund_observation_input( - input.observation_id, - input.source, - input.notification_ref, - input.payload_fingerprint, - input.out_refund_no, - input.provider_refund_id, - input.order_id, - input.provider_transaction_id, - input.provider_status, - input.total_cents, - input.refund_cents, - input.payer_total_cents, - input.payer_refund_cents, - input.success_at_micros, - input.observed_at_micros, - )?; - let observed_at = Timestamp::from_micros_since_unix_epoch(validated.observed_at_micros); - let success_at = validated - .success_at_micros - .map(Timestamp::from_micros_since_unix_epoch); - - if let Some(existing_observation) = ctx - .db - .profile_recharge_refund_observation() - .observation_id() - .find(&validated.observation_id) - { - validate_profile_recharge_refund_observation_replay(&existing_observation, &validated)?; - let mut refund = ctx - .db - .profile_recharge_refund() - .out_refund_no() - .find(&validated.out_refund_no) - .ok_or_else(|| "退款 observation 已存在但退款单缺失".to_string())?; - if observed_at.to_micros_since_unix_epoch() > refund.updated_at.to_micros_since_unix_epoch() - { - refund.updated_at = observed_at; - refund.last_observation_source = validated.source; - refund.last_observation_id = validated.observation_id.clone(); - } - let (settlement, resolution_code) = - reconcile_profile_recharge_refund_success(ctx, &mut refund); - sync_profile_recharge_refund_hold_with_provider_status(ctx, &refund); - upsert_profile_recharge_refund_row(ctx, refund.clone()); - return Ok(( - build_profile_recharge_refund_snapshot_from_row(&refund), - settlement - .map(|row| build_profile_recharge_order_refund_settlement_snapshot_from_row(&row)), - true, - resolution_code, - )); - } - - if let Some(provider_collision) = ctx - .db - .profile_recharge_refund() - .provider_refund_id() - .find(&validated.provider_refund_id) - .filter(|row| row.out_refund_no != validated.out_refund_no) - { - insert_profile_recharge_refund_observation( - ctx, - &validated, - observed_at, - success_at, - "provider_refund_id_conflict", - ); - let settlement = ctx - .db - .profile_recharge_order_refund_settlement() - .order_id() - .find(&provider_collision.order_id) - .map(|row| build_profile_recharge_order_refund_settlement_snapshot_from_row(&row)); - return Ok(( - build_profile_recharge_refund_snapshot_from_row(&provider_collision), - settlement, - false, - "provider_refund_id_conflict".to_string(), - )); - } - - let existing = ctx - .db - .profile_recharge_refund() - .out_refund_no() - .find(&validated.out_refund_no); - let mut refund = match existing { - Some(mut row) => { - if !profile_recharge_refund_matches_observation(&row, &validated) { - insert_profile_recharge_refund_observation( - ctx, - &validated, - observed_at, - success_at, - "immutable_conflict", - ); - let settlement = ctx - .db - .profile_recharge_order_refund_settlement() - .order_id() - .find(&row.order_id) - .map(|value| { - build_profile_recharge_order_refund_settlement_snapshot_from_row(&value) - }); - return Ok(( - build_profile_recharge_refund_snapshot_from_row(&row), - settlement, - false, - "immutable_conflict".to_string(), - )); - } - - let transition = resolve_runtime_profile_recharge_refund_status_transition( - row.provider_status, - validated.provider_status, - ); - if transition == RuntimeProfileRechargeRefundStatusTransition::Conflict { - insert_profile_recharge_refund_observation( - ctx, - &validated, - observed_at, - success_at, - "status_conflict", - ); - let settlement = ctx - .db - .profile_recharge_order_refund_settlement() - .order_id() - .find(&row.order_id) - .map(|value| { - build_profile_recharge_order_refund_settlement_snapshot_from_row(&value) - }); - return Ok(( - build_profile_recharge_refund_snapshot_from_row(&row), - settlement, - false, - "status_conflict".to_string(), - )); - } - if transition == RuntimeProfileRechargeRefundStatusTransition::Advance { - row.provider_status = validated.provider_status; - row.success_at = success_at; - } - row.updated_at = latest_profile_recharge_refund_timestamp(row.updated_at, observed_at); - row.last_observation_source = validated.source; - row.last_observation_id = validated.observation_id.clone(); - row - } - None => ProfileRechargeRefund { - out_refund_no: validated.out_refund_no.clone(), - provider_refund_id: validated.provider_refund_id.clone(), - order_id: validated.order_id.clone(), - provider_transaction_id: validated.provider_transaction_id.clone(), - user_id: None, - provider_status: validated.provider_status, - total_cents: validated.total_cents, - refund_cents: validated.refund_cents, - payer_total_cents: validated.payer_total_cents, - payer_refund_cents: validated.payer_refund_cents, - success_at, - first_observed_at: observed_at, - updated_at: observed_at, - last_observation_source: validated.source, - last_observation_id: validated.observation_id.clone(), - order_settled_at: None, - target_recovery_points: 0, - recovered_points: 0, - unrecovered_points: 0, - recovery_status: if validated.provider_status - == RuntimeProfileRechargeRefundStatus::Success - { - RuntimeProfileRechargeRefundRecoveryStatus::Pending - } else { - RuntimeProfileRechargeRefundRecoveryStatus::NotApplicable - }, - last_recovery_ledger_id: None, - last_error_code: None, - manual_review_resolved_by_admin_user_id: None, - manual_review_resolution_reason: None, - manual_review_resolved_at: None, - manual_review_resolved_error_code: None, - }, - }; - - let (settlement, resolution_code) = reconcile_profile_recharge_refund_success(ctx, &mut refund); - sync_profile_recharge_refund_hold_with_provider_status(ctx, &refund); - upsert_profile_recharge_refund_row(ctx, refund.clone()); - insert_profile_recharge_refund_observation( - ctx, - &validated, - observed_at, - success_at, - &resolution_code, - ); - - Ok(( - build_profile_recharge_refund_snapshot_from_row(&refund), - settlement - .map(|row| build_profile_recharge_order_refund_settlement_snapshot_from_row(&row)), - false, - resolution_code, - )) -} - -fn reconcile_profile_recharge_refund_success( - ctx: &ReducerContext, - refund: &mut ProfileRechargeRefund, -) -> (Option, String) { - if refund.provider_status != RuntimeProfileRechargeRefundStatus::Success { - refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::NotApplicable; - refund.last_error_code = None; - return (None, "recorded_non_success".to_string()); - } - - let Some(mut order) = ctx - .db - .profile_recharge_order() - .order_id() - .find(&refund.order_id) - else { - refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; - refund.last_error_code = Some("order_missing".to_string()); - return (None, "order_missing".to_string()); - }; - refund.user_id = Some(order.user_id.clone()); - - let mismatch_code = unresolved_profile_recharge_refund_order_match_error(&order, refund); - if let Some(code) = mismatch_code { - refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; - refund.last_error_code = Some(code.clone()); - let mut settlement = profile_recharge_order_refund_settlement_row(ctx, &order); - settlement.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; - settlement.wallet_frozen |= - profile_recharge_refund_manual_review_freezes_wallet(order.kind, code.as_str()); - settlement.updated_at = ctx.timestamp; - upsert_profile_recharge_order_refund_settlement_row(ctx, settlement.clone()); - return (Some(settlement), code); - } - - let mut settlement = profile_recharge_order_refund_settlement_row(ctx, &order); - if refund.order_settled_at.is_none() { - let order_points_delta = if order.kind == RuntimeProfileRechargeProductKind::Points { - order.points_delta - } else { - 0 - }; - let plan = match build_runtime_profile_recharge_refund_settlement_plan( - settlement.successful_refund_count, - settlement.cumulative_success_refund_cents, - settlement.target_recovery_points, - refund.refund_cents, - order.amount_cents, - order_points_delta, - ) { - Ok(plan) => plan, - Err(_) => { - return mark_profile_recharge_refund_manual_review( - ctx, - refund, - settlement, - order.kind, - "refund_settlement_plan_invalid", - ); - } - }; - - settlement.successful_refund_count = plan.successful_refund_count; - settlement.cumulative_success_refund_cents = plan.cumulative_success_refund_cents; - refund.order_settled_at = Some(ctx.timestamp); - if plan.order_fully_refunded { - order.status = RuntimeProfileRechargeOrderStatus::Refunded; - upsert_profile_recharge_order_row(ctx, order.clone()); - } - match order.kind { - RuntimeProfileRechargeProductKind::Points => { - refund.target_recovery_points = plan.incremental_target_recovery_points; - refund.unrecovered_points = refund - .target_recovery_points - .saturating_sub(refund.recovered_points); - settlement.target_recovery_points = plan.target_recovery_points; - settlement.unrecovered_points = settlement - .target_recovery_points - .saturating_sub(settlement.recovered_points); - } - RuntimeProfileRechargeProductKind::Membership => { - refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; - refund.last_error_code = Some("membership_manual_review".to_string()); - settlement.recovery_status = - RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; - } - } - } - - if order.kind == RuntimeProfileRechargeProductKind::Membership { - settlement.updated_at = ctx.timestamp; - upsert_profile_recharge_order_refund_settlement_row(ctx, settlement.clone()); - return (Some(settlement), "membership_manual_review".to_string()); - } - - let outstanding = refund - .target_recovery_points - .saturating_sub(refund.recovered_points); - if outstanding > 0 { - match apply_profile_recharge_refund_permanent_points_recovery(ctx, refund, outstanding) { - Ok((recovered, ledger_id)) => { - refund.recovered_points = refund.recovered_points.saturating_add(recovered); - refund.unrecovered_points = refund - .target_recovery_points - .saturating_sub(refund.recovered_points); - refund.last_recovery_ledger_id = ledger_id; - settlement.recovered_points = settlement.recovered_points.saturating_add(recovered); - settlement.unrecovered_points = settlement - .target_recovery_points - .saturating_sub(settlement.recovered_points); - } - Err(code) => { - refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; - refund.last_error_code = Some(code.clone()); - settlement.recovery_status = - RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; - settlement.updated_at = ctx.timestamp; - upsert_profile_recharge_order_refund_settlement_row(ctx, settlement.clone()); - return (Some(settlement), code); - } - } - } - - if refund.unrecovered_points == 0 { - refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::Applied; - refund.last_error_code = None; - } else { - refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::Shortfall; - refund.last_error_code = Some("permanent_points_shortfall".to_string()); - } - settlement.recovery_status = if settlement.unrecovered_points == 0 { - RuntimeProfileRechargeRefundRecoveryStatus::Applied - } else { - RuntimeProfileRechargeRefundRecoveryStatus::Shortfall - }; - settlement.wallet_frozen = settlement.unrecovered_points > 0; - settlement.updated_at = ctx.timestamp; - upsert_profile_recharge_order_refund_settlement_row(ctx, settlement.clone()); - - let resolution_code = if refund.unrecovered_points == 0 { - "settled" - } else { - "settled_shortfall" - }; - (Some(settlement), resolution_code.to_string()) -} - -#[cfg(test)] -fn validate_profile_recharge_refund_order_match( - order: &ProfileRechargeOrder, - refund: &ProfileRechargeRefund, -) -> Result<(), String> { - profile_recharge_refund_order_match_error_except(order, refund, None).map_or(Ok(()), Err) -} - -fn profile_recharge_refund_order_match_error_except( - order: &ProfileRechargeOrder, - refund: &ProfileRechargeRefund, - approved_error_code: Option<&str>, -) -> Option { - if !matches!( - order.payment_channel.as_str(), - PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM - | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI - | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 - | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE - ) { - return Some("payment_channel_not_v3".to_string()); - } - if order.paid_at.is_none() - || !matches!( - order.status, - RuntimeProfileRechargeOrderStatus::Paid | RuntimeProfileRechargeOrderStatus::Refunded - ) - { - return Some("order_not_paid".to_string()); - } - if order.provider_transaction_id.as_deref() != Some(refund.provider_transaction_id.as_str()) - && approved_error_code != Some("provider_transaction_id_mismatch") - { - return Some("provider_transaction_id_mismatch".to_string()); - } - if order.amount_cents != refund.total_cents - && approved_error_code != Some("order_total_mismatch") - { - return Some("order_total_mismatch".to_string()); - } - None -} - -fn unresolved_profile_recharge_refund_order_match_error( - order: &ProfileRechargeOrder, - refund: &ProfileRechargeRefund, -) -> Option { - let approved_error_code = refund - .manual_review_resolved_at - .is_some() - .then_some(refund.manual_review_resolved_error_code.as_deref()) - .flatten(); - profile_recharge_refund_order_match_error_except(order, refund, approved_error_code) -} - -fn mark_profile_recharge_refund_manual_review( - ctx: &ReducerContext, - refund: &mut ProfileRechargeRefund, - mut settlement: ProfileRechargeOrderRefundSettlement, - order_kind: RuntimeProfileRechargeProductKind, - code: &str, -) -> (Option, String) { - refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; - refund.last_error_code = Some(code.to_string()); - settlement.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; - settlement.wallet_frozen |= - profile_recharge_refund_manual_review_freezes_wallet(order_kind, code); - settlement.updated_at = ctx.timestamp; - upsert_profile_recharge_order_refund_settlement_row(ctx, settlement.clone()); - (Some(settlement), code.to_string()) -} - -fn profile_recharge_refund_manual_review_freezes_wallet( - order_kind: RuntimeProfileRechargeProductKind, - code: &str, -) -> bool { - order_kind == RuntimeProfileRechargeProductKind::Points - && matches!( - code, - "provider_transaction_id_mismatch" - | "order_total_mismatch" - | "refund_settlement_plan_invalid" - ) -} - -fn apply_profile_recharge_refund_permanent_points_recovery( - ctx: &ReducerContext, - refund: &ProfileRechargeRefund, - outstanding_points: u64, -) -> Result<(u64, Option), String> { - let user_id = refund - .user_id - .as_deref() - .ok_or_else(|| "refund_user_missing".to_string())?; - refresh_profile_wallet_expiring_points(ctx, user_id, ctx.timestamp); - let current = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&user_id.to_string()); - let wallet_total = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0); - let daily_free = ctx - .db - .profile_daily_free_points() - .user_id() - .find(&user_id.to_string()) - .map(|row| row.remaining_points) - .unwrap_or(0); - let membership_limited = ctx - .db - .profile_membership() - .user_id() - .find(&user_id.to_string()) - .filter(|row| active_membership_row_at(row, ctx.timestamp)) - .map(|row| row.cycle_remaining_points) - .unwrap_or(0); - let unrelated_held_points = - active_profile_recharge_refund_unrelated_hold_points(ctx, user_id, refund); - let (recoverable, _) = resolve_runtime_profile_recharge_refund_recovery_with_holds( - outstanding_points, - wallet_total, - daily_free, - membership_limited, - unrelated_held_points, - ); - if recoverable == 0 { - return Ok((0, None)); - } - - let amount_delta = - -i64::try_from(recoverable).map_err(|_| "refund_recovery_points_overflow".to_string())?; - let next_recovered = refund.recovered_points.saturating_add(recoverable); - let ledger_id = format!( - "recharge-refund-recovery:{}:{}", - refund.out_refund_no, next_recovered - ); - if ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&ledger_id) - .is_some() - { - return Err("refund_recovery_ledger_conflict".to_string()); - } - let Some(existing) = current else { - return Err("refund_wallet_state_missing".to_string()); - }; - let next_balance = existing - .wallet_balance - .checked_sub(recoverable) - .ok_or_else(|| "refund_wallet_balance_underflow".to_string())?; - ctx.db - .profile_dashboard_state() - .user_id() - .delete(&existing.user_id); - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: existing.user_id.clone(), - wallet_balance: next_balance, - total_play_time_ms: existing.total_play_time_ms, - created_at: existing.created_at, - updated_at: ctx.timestamp, - }); - let metadata_json = metadata_with_profile_wallet_delta_split( - &json!({ - "rechargeOrderId": refund.order_id, - "outRefundNo": refund.out_refund_no, - "providerRefundId": refund.provider_refund_id, - }) - .to_string(), - 0, - 0, - amount_delta, - None, - None, - ); - ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger { - wallet_ledger_id: ledger_id.clone(), - user_id: existing.user_id, - amount_delta, - balance_after: next_balance, - source_type: RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery, - created_at: ctx.timestamp, - metadata_json: Some(metadata_json), - }); - Ok((recoverable, Some(ledger_id))) -} - -fn repay_profile_recharge_refund_debt_from_permanent_points(ctx: &ReducerContext, user_id: &str) { - let mut candidates = ctx - .db - .profile_recharge_refund() - .iter() - .filter(|row| { - row.user_id.as_deref() == Some(user_id) - && row.provider_status == RuntimeProfileRechargeRefundStatus::Success - && row.unrecovered_points > 0 - && matches!( - row.recovery_status, - RuntimeProfileRechargeRefundRecoveryStatus::Pending - | RuntimeProfileRechargeRefundRecoveryStatus::Shortfall - ) - }) - .collect::>(); - candidates.sort_by(|left, right| { - left.first_observed_at - .to_micros_since_unix_epoch() - .cmp(&right.first_observed_at.to_micros_since_unix_epoch()) - .then_with(|| left.out_refund_no.cmp(&right.out_refund_no)) - }); - - for mut refund in candidates { - reconcile_profile_recharge_refund_success(ctx, &mut refund); - upsert_profile_recharge_refund_row(ctx, refund); - } -} - -fn profile_recharge_order_refund_settlement_row( - ctx: &ReducerContext, - order: &ProfileRechargeOrder, -) -> ProfileRechargeOrderRefundSettlement { - ctx.db - .profile_recharge_order_refund_settlement() - .order_id() - .find(&order.order_id) - .unwrap_or_else(|| ProfileRechargeOrderRefundSettlement { - order_id: order.order_id.clone(), - user_id: order.user_id.clone(), - successful_refund_count: 0, - cumulative_success_refund_cents: 0, - target_recovery_points: 0, - recovered_points: 0, - unrecovered_points: 0, - recovery_status: RuntimeProfileRechargeRefundRecoveryStatus::Pending, - wallet_frozen: false, - updated_at: ctx.timestamp, - }) -} - -fn profile_recharge_refund_matches_observation( - row: &ProfileRechargeRefund, - input: &RuntimeProfileRechargeRefundObservationInput, -) -> bool { - row.provider_refund_id == input.provider_refund_id - && row.order_id == input.order_id - && row.provider_transaction_id == input.provider_transaction_id - && row.total_cents == input.total_cents - && row.refund_cents == input.refund_cents - && row.payer_total_cents == input.payer_total_cents - && row.payer_refund_cents == input.payer_refund_cents - && (row.provider_status != RuntimeProfileRechargeRefundStatus::Success - || row - .success_at - .map(|value| value.to_micros_since_unix_epoch()) - == input.success_at_micros) -} - -fn validate_profile_recharge_refund_observation_replay( - row: &ProfileRechargeRefundObservation, - input: &RuntimeProfileRechargeRefundObservationInput, -) -> Result<(), String> { - let success_at_micros = row - .success_at - .map(|value| value.to_micros_since_unix_epoch()); - if row.out_refund_no != input.out_refund_no - || row.provider_refund_id != input.provider_refund_id - || row.order_id != input.order_id - || row.provider_transaction_id != input.provider_transaction_id - || row.source != input.source - || row.provider_status != input.provider_status - || row.total_cents != input.total_cents - || row.refund_cents != input.refund_cents - || row.payer_total_cents != input.payer_total_cents - || row.payer_refund_cents != input.payer_refund_cents - || success_at_micros != input.success_at_micros - || row.notification_ref != input.notification_ref - || row.payload_fingerprint != input.payload_fingerprint - { - return Err("退款 observation_id 重放内容冲突".to_string()); - } - Ok(()) -} - -fn insert_profile_recharge_refund_observation( - ctx: &ReducerContext, - input: &RuntimeProfileRechargeRefundObservationInput, - observed_at: Timestamp, - success_at: Option, - resolution_code: &str, -) { - ctx.db - .profile_recharge_refund_observation() - .insert(ProfileRechargeRefundObservation { - observation_id: input.observation_id.clone(), - out_refund_no: input.out_refund_no.clone(), - provider_refund_id: input.provider_refund_id.clone(), - order_id: input.order_id.clone(), - provider_transaction_id: input.provider_transaction_id.clone(), - source: input.source, - provider_status: input.provider_status, - total_cents: input.total_cents, - refund_cents: input.refund_cents, - payer_total_cents: input.payer_total_cents, - payer_refund_cents: input.payer_refund_cents, - success_at, - notification_ref: input.notification_ref.clone(), - payload_fingerprint: input.payload_fingerprint.clone(), - resolution_code: resolution_code.to_string(), - observed_at, - }); -} - -fn upsert_profile_recharge_refund_row(ctx: &ReducerContext, row: ProfileRechargeRefund) { - ctx.db - .profile_recharge_refund() - .out_refund_no() - .delete(&row.out_refund_no); - ctx.db.profile_recharge_refund().insert(row); -} - -fn upsert_profile_recharge_order_refund_settlement_row( - ctx: &ReducerContext, - row: ProfileRechargeOrderRefundSettlement, -) { - ctx.db - .profile_recharge_order_refund_settlement() - .order_id() - .delete(&row.order_id); - ctx.db - .profile_recharge_order_refund_settlement() - .insert(row); -} - -fn upsert_profile_recharge_order_row(ctx: &ReducerContext, row: ProfileRechargeOrder) { - ctx.db - .profile_recharge_order() - .order_id() - .delete(&row.order_id); - ctx.db.profile_recharge_order().insert(row); -} - -fn latest_profile_recharge_refund_timestamp(current: Timestamp, observed: Timestamp) -> Timestamp { - if observed.to_micros_since_unix_epoch() > current.to_micros_since_unix_epoch() { - observed - } else { - current - } -} - -fn list_profile_recharge_refund_reconciliation_rows( - ctx: &ReducerContext, - input: RuntimeProfileRechargeRefundReconciliationListInput, -) -> Vec { - let validated = build_runtime_profile_recharge_refund_reconciliation_list_input(input.limit); - let mut candidates = HashMap::::new(); - for status in [ - RuntimeProfileRechargeRefundStatus::Processing, - RuntimeProfileRechargeRefundStatus::Abnormal, - RuntimeProfileRechargeRefundStatus::Success, - ] { - for row in ctx - .db - .profile_recharge_refund() - .by_profile_recharge_refund_status_updated_at() - .filter(status) - { - let needs_reconciliation = profile_recharge_refund_needs_reconciliation(&row); - if needs_reconciliation { - candidates.insert(row.out_refund_no.clone(), row); - } - } - } - let mut rows = candidates.into_values().collect::>(); - rows.sort_by(|left, right| { - left.updated_at - .to_micros_since_unix_epoch() - .cmp(&right.updated_at.to_micros_since_unix_epoch()) - .then_with(|| left.out_refund_no.cmp(&right.out_refund_no)) - }); - let rotation_slot = ctx - .timestamp - .to_micros_since_unix_epoch() - .div_euclid(60 * 1_000_000) - .unsigned_abs(); - select_profile_recharge_refund_reconciliation_page( - rows, - validated.limit as usize, - rotation_slot, - ) - .into_iter() - .map(|row| build_profile_recharge_refund_snapshot_from_row(&row)) - .collect() -} - -fn profile_recharge_refund_needs_reconciliation(row: &ProfileRechargeRefund) -> bool { - if row.provider_status != RuntimeProfileRechargeRefundStatus::Success { - return row.provider_status != RuntimeProfileRechargeRefundStatus::Closed; - } - match row.recovery_status { - RuntimeProfileRechargeRefundRecoveryStatus::Pending - | RuntimeProfileRechargeRefundRecoveryStatus::Shortfall => true, - RuntimeProfileRechargeRefundRecoveryStatus::ManualReview => matches!( - row.last_error_code.as_deref(), - Some("order_missing" | "order_not_paid") - ), - RuntimeProfileRechargeRefundRecoveryStatus::Applied - | RuntimeProfileRechargeRefundRecoveryStatus::NotApplicable => false, - } -} - -fn select_profile_recharge_refund_reconciliation_page( - rows: Vec, - limit: usize, - rotation_slot: u64, -) -> Vec { - select_rotating_reconciliation_page(rows, limit, rotation_slot) -} - -fn select_profile_recharge_refund_hold_reconciliation_page( - rows: Vec, - limit: usize, - rotation_slot: u64, -) -> Vec { - select_rotating_reconciliation_page(rows, limit, rotation_slot) -} - -fn select_rotating_reconciliation_page( - rows: Vec, - limit: usize, - rotation_slot: u64, -) -> Vec { - if rows.len() <= limit || limit == 0 { - return rows; - } - let page_count = rows.len().div_ceil(limit); - let page_index = usize::try_from(rotation_slot % page_count as u64).unwrap_or(0); - rows.into_iter() - .skip(page_index.saturating_mul(limit)) - .take(limit) - .collect() -} - -fn advance_profile_recharge_refund_bill_checkpoint( - ctx: &ReducerContext, - input: RuntimeProfileRechargeRefundBillCheckpointAdvanceInput, -) -> Result { - let validated = build_runtime_profile_recharge_refund_bill_checkpoint_advance_input( - input.checkpoint_id, - input.bill_date, - input.bill_hash, - input.processed_refund_count, - input.completed_at_micros, - )?; - let completed_at = Timestamp::from_micros_since_unix_epoch(validated.completed_at_micros); - if let Some(existing) = ctx - .db - .profile_recharge_refund_bill_checkpoint() - .checkpoint_id() - .find(&validated.checkpoint_id) - { - if validated.bill_date < existing.bill_date { - return Err("退款账单 checkpoint 不能回退".to_string()); - } - if validated.bill_date == existing.bill_date { - if validated.bill_hash != existing.bill_hash - || validated.processed_refund_count != existing.processed_refund_count - { - return Err("同一退款账单日期的 hash 或退款条数冲突".to_string()); - } - return Ok(build_profile_recharge_refund_bill_checkpoint_snapshot_from_row(&existing)); - } - ctx.db - .profile_recharge_refund_bill_checkpoint() - .checkpoint_id() - .delete(&existing.checkpoint_id); - } - let row = ProfileRechargeRefundBillCheckpoint { - checkpoint_id: validated.checkpoint_id, - bill_date: validated.bill_date, - bill_hash: validated.bill_hash, - processed_refund_count: validated.processed_refund_count, - completed_at, - updated_at: ctx.timestamp, - }; - ctx.db - .profile_recharge_refund_bill_checkpoint() - .insert(row.clone()); - Ok(build_profile_recharge_refund_bill_checkpoint_snapshot_from_row(&row)) -} - -fn claim_profile_recharge_order_expiration_schedules( - ctx: &ReducerContext, - input: RuntimeProfileRechargeOrderExpirationClaimInput, -) -> Result, String> { - let validated_input = build_runtime_profile_recharge_order_expiration_claim_input( - input.worker_id, - input.now_micros, - input.lease_expires_at_micros, - input.limit, - ) - .map_err(|error| error.to_string())?; - let now = Timestamp::from_micros_since_unix_epoch(validated_input.now_micros); - let lease_expires_at = - Timestamp::from_micros_since_unix_epoch(validated_input.lease_expires_at_micros); - let limit = if validated_input.limit == 0 { - PROFILE_RECHARGE_ORDER_EXPIRATION_CLAIM_LIMIT_DEFAULT - } else { - validated_input - .limit - .min(PROFILE_RECHARGE_ORDER_EXPIRATION_CLAIM_LIMIT_MAX) - } as usize; - - let mut candidates = ctx - .db - .profile_recharge_order_expiration_schedule() - .iter() - .filter(|row| { - row.scheduled_at <= now - && row - .lease_expires_at - .map(|value| value <= now) - .unwrap_or(true) - }) - .collect::>(); - candidates.sort_by_key(|row| row.scheduled_at.to_micros_since_unix_epoch()); - - let mut claimed = Vec::new(); - for mut schedule in candidates.into_iter().take(limit) { - let should_claim = ctx - .db - .profile_recharge_order() - .order_id() - .find(&schedule.order_id) - .map(|order| order.status == RuntimeProfileRechargeOrderStatus::Pending) - .unwrap_or(false); - if !should_claim { - delete_profile_recharge_order_expiration_schedule(ctx, &schedule.order_id); - continue; - } - - ctx.db - .profile_recharge_order_expiration_schedule() - .order_id() - .delete(&schedule.order_id); - schedule.lease_owner = Some(validated_input.worker_id.clone()); - schedule.lease_expires_at = Some(lease_expires_at); - schedule.updated_at = now; - ctx.db - .profile_recharge_order_expiration_schedule() - .insert(schedule.clone()); - claimed.push(build_profile_recharge_order_expiration_schedule_snapshot_from_row(&schedule)); - } - - Ok(claimed) -} - -fn list_unchecked_expired_profile_recharge_order_rows( - ctx: &ReducerContext, - input: RuntimeProfileRechargeOrderExpirationCheckListInput, -) -> Result, String> { - let validated_input = - build_runtime_profile_recharge_order_expiration_check_list_input(input.limit) - .map_err(|error| error.to_string())?; - let limit = if validated_input.limit == 0 { - PROFILE_RECHARGE_ORDER_EXPIRATION_CHECK_LIMIT_DEFAULT - } else { - validated_input - .limit - .min(PROFILE_RECHARGE_ORDER_EXPIRATION_CHECK_LIMIT_MAX) - } as usize; - - let mut orders = ctx - .db - .profile_recharge_order() - .iter() - .filter(|row| { - row.status == RuntimeProfileRechargeOrderStatus::Expired - && row.expiration_checked_at.is_none() - }) - .collect::>(); - orders.sort_by(|left, right| { - left.expired_at - .map(|value| value.to_micros_since_unix_epoch()) - .unwrap_or_else(|| left.created_at.to_micros_since_unix_epoch()) - .cmp( - &right - .expired_at - .map(|value| value.to_micros_since_unix_epoch()) - .unwrap_or_else(|| right.created_at.to_micros_since_unix_epoch()), - ) - .then_with(|| left.order_id.cmp(&right.order_id)) - }); - - Ok(orders - .into_iter() - .take(limit) - .map(|row| build_profile_recharge_order_snapshot_from_row(&row)) - .collect()) -} - -fn mark_profile_recharge_order_expiration_checked_record( - ctx: &ReducerContext, - input: RuntimeProfileRechargeOrderExpirationCheckInput, -) -> Result { - let validated_input = build_runtime_profile_recharge_order_expiration_check_input( - input.order_id, - input.checked_at_micros, - input.provider_state, - input.last_error, - ) - .map_err(|error| error.to_string())?; - let mut order = ctx - .db - .profile_recharge_order() - .order_id() - .find(&validated_input.order_id) - .ok_or_else(|| "profile_recharge_order missing".to_string())?; - ctx.db - .profile_recharge_order() - .order_id() - .delete(&order.order_id); - order.expiration_checked_at = validated_input - .checked_at_micros - .map(Timestamp::from_micros_since_unix_epoch); - order.expiration_provider_state = validated_input.provider_state; - order.expiration_last_error = validated_input.last_error; - ctx.db.profile_recharge_order().insert(order.clone()); - Ok(build_profile_recharge_order_snapshot_from_row(&order)) -} - -fn complete_profile_recharge_order_expiration_schedule( - ctx: &ReducerContext, - input: RuntimeProfileRechargeOrderExpirationCompleteInput, -) -> Result<(), String> { - let validated_input = - build_runtime_profile_recharge_order_expiration_complete_input(input.order_id) - .map_err(|error| error.to_string())?; - delete_profile_recharge_order_expiration_schedule(ctx, &validated_input.order_id); - Ok(()) -} - -fn close_profile_recharge_order_record( - ctx: &ReducerContext, - input: RuntimeProfileRechargeOrderCloseInput, -) -> Result< - ( - RuntimeProfileRechargeCenterSnapshot, - RuntimeProfileRechargeOrderSnapshot, - ), - String, -> { - let validated_input = - build_runtime_profile_recharge_order_close_input(input.order_id, input.closed_at_micros) - .map_err(|error| error.to_string())?; - let mut order = ctx - .db - .profile_recharge_order() - .order_id() - .find(&validated_input.order_id) - .ok_or_else(|| "profile_recharge_order 不存在".to_string())?; - - if order.status == RuntimeProfileRechargeOrderStatus::Pending { - ctx.db - .profile_recharge_order() - .order_id() - .delete(&order.order_id); - order.status = RuntimeProfileRechargeOrderStatus::Closed; - ctx.db.profile_recharge_order().insert(order.clone()); - } - delete_profile_recharge_order_expiration_task(ctx, &order.order_id); - - Ok(( - build_profile_recharge_center_snapshot(ctx, &order.user_id), - build_profile_recharge_order_snapshot_from_row(&order), - )) -} - -fn should_schedule_profile_recharge_order_expiration(payment_channel: &str) -> bool { - matches!( - payment_channel, - PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM - | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL - | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 - | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI - | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE - ) -} - -fn delete_profile_recharge_order_expiration_task(ctx: &ReducerContext, order_id: &str) { - delete_profile_recharge_order_expiration_timer(ctx, order_id); - delete_profile_recharge_order_expiration_schedule(ctx, order_id); -} - -fn delete_profile_recharge_order_expiration_timer(ctx: &ReducerContext, order_id: &str) { - let order_id = order_id.to_string(); - if let Some(timer) = ctx - .db - .profile_recharge_order_expiration_timer() - .order_id() - .find(&order_id) - { - ctx.db - .profile_recharge_order_expiration_timer() - .scheduled_id() - .delete(&timer.scheduled_id); - } -} - -fn delete_profile_recharge_order_expiration_schedule(ctx: &ReducerContext, order_id: &str) { - ctx.db - .profile_recharge_order_expiration_schedule() - .order_id() - .delete(&order_id.to_string()); -} - -fn apply_profile_recharge_purchase( - ctx: &ReducerContext, - user_id: &str, - product: &RuntimeProfileRechargeProductSnapshot, - order_created_at_micros: i64, - paid_at: Timestamp, -) -> Result<(i64, Option), String> { - match product.kind { - RuntimeProfileRechargeProductKind::Points => { - let has_recharged = has_profile_product_recharged(ctx, user_id, &product.product_id); - let points_delta = - resolve_runtime_profile_points_recharge_delta(product, has_recharged); - apply_profile_wallet_delta( - ctx, - user_id, - points_delta, - RuntimeProfileWalletLedgerSourceType::PointsRecharge, - &build_runtime_profile_recharge_wallet_ledger_id( - user_id, - order_created_at_micros, - &product.product_id, - ), - paid_at, - )?; - Ok((points_delta as i64, None)) - } - RuntimeProfileRechargeProductKind::Membership => { - let purchase_result = - apply_profile_membership_purchase(ctx, user_id, product, paid_at)?; - Ok(( - purchase_result.period_points_delta as i64, - Some(purchase_result.expires_at), - )) - } - } -} - -fn submit_profile_feedback_record( - ctx: &ReducerContext, - input: RuntimeProfileFeedbackSubmissionInput, -) -> Result { - let validated_input = build_runtime_profile_feedback_submission_input( - input.user_id, - input.description, - input.contact_phone, - input.evidence_items, - input.created_at_micros, - ) - .map_err(|error| error.to_string())?; - let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros); - let feedback_id = build_runtime_profile_feedback_submission_id( - &validated_input.user_id, - validated_input.created_at_micros, - ); - let evidence_json = serde_json::to_string(&validated_input.evidence_items) - .map_err(|error| format!("反馈凭证序列化失败: {error}"))?; - let row = ProfileFeedbackSubmission { - feedback_id: feedback_id.clone(), - user_id: validated_input.user_id, - description: validated_input.description, - contact_phone: validated_input.contact_phone, - evidence_json, - status: RuntimeProfileFeedbackStatus::Open, - created_at, - updated_at: created_at, - }; - ctx.db.profile_feedback_submission().insert(row); - - let latest = ctx - .db - .profile_feedback_submission() - .feedback_id() - .find(&feedback_id) - .ok_or_else(|| "profile_feedback_submission 写入后未能读取".to_string())?; - - Ok(build_profile_feedback_submission_snapshot_from_row(&latest)) -} - -fn get_profile_referral_invite_center_snapshot( - ctx: &ReducerContext, - input: RuntimeReferralInviteCenterGetInput, -) -> Result { - let validated_input = build_runtime_referral_invite_center_get_input(input.user_id) - .map_err(|error| error.to_string())?; - Ok(build_profile_referral_invite_center_snapshot( - ctx, - &validated_input.user_id, - )) -} - -fn redeem_profile_referral_invite_code_record( - ctx: &ReducerContext, - input: RuntimeReferralRedeemInput, -) -> Result { - let validated_input = build_runtime_referral_redeem_input( - input.user_id, - input.invite_code, - input.updated_at_micros, - ) - .map_err(|error| error.to_string())?; - let bound_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); - let invitee_user_id = validated_input.user_id; - let invite_code = validated_input.invite_code; - - if ctx - .db - .user_account() - .user_id() - .find(&invitee_user_id) - .is_none() - { - return Err("用户不存在".to_string()); - } - - if ctx - .db - .profile_referral_relation() - .invitee_user_id() - .find(&invitee_user_id) - .is_some() - { - return Err("每个用户最多只能填写一个邀请码".to_string()); - } - - let inviter_code = ctx - .db - .profile_invite_code() - .invite_code() - .find(&invite_code) - .ok_or_else(|| "邀请码不存在".to_string())?; - validate_profile_invite_code_redeem_time(&inviter_code, validated_input.updated_at_micros)?; - if inviter_code.user_id == invitee_user_id { - return Err("不能填写自己的邀请码".to_string()); - } - let invite_metadata_user_tags = - profile_invite_code_metadata_user_tags(&inviter_code.metadata_json)?; - - let invitee_balance_after = apply_profile_wallet_delta( - ctx, - &invitee_user_id, - PROFILE_REFERRAL_REWARD_POINTS, - RuntimeProfileWalletLedgerSourceType::InviteInviteeReward, - &build_runtime_profile_referral_invitee_ledger_id( - &invitee_user_id, - validated_input.updated_at_micros, - ), - bound_at, - )?; - let is_admin_invite_code = is_admin_profile_invite_code_user_id(&inviter_code.user_id); - let today_inviter_reward_count = if is_admin_invite_code { - 0 - } else { - count_today_profile_referral_inviter_rewards(ctx, &inviter_code.user_id, bound_at) - }; - let inviter_reward_granted = !is_admin_invite_code - && module_runtime::should_grant_runtime_profile_inviter_reward(today_inviter_reward_count); - let inviter_balance_after = if inviter_reward_granted { - apply_profile_wallet_delta( - ctx, - &inviter_code.user_id, - PROFILE_REFERRAL_REWARD_POINTS, - RuntimeProfileWalletLedgerSourceType::InviteInviterReward, - &build_runtime_profile_referral_inviter_ledger_id( - &inviter_code.user_id, - validated_input.updated_at_micros, - ), - bound_at, - )? - } else { - profile_wallet_balance(ctx, &inviter_code.user_id) - }; - - ctx.db - .profile_referral_relation() - .insert(ProfileReferralRelation { - invitee_user_id: invitee_user_id.clone(), - inviter_user_id: inviter_code.user_id, - invite_code, - inviter_reward_granted, - invitee_reward_granted: true, - bound_at, - }); - merge_user_account_tags(ctx, &invitee_user_id, invite_metadata_user_tags)?; - - Ok(RuntimeReferralRedeemSnapshot { - center: build_profile_referral_invite_center_snapshot(ctx, &invitee_user_id), - invitee_reward_granted: true, - inviter_reward_granted, - invitee_balance_after, - inviter_balance_after, - }) -} - -fn redeem_profile_reward_code_record( - ctx: &ReducerContext, - input: RuntimeProfileRewardCodeRedeemInput, -) -> Result { - let validated_input = build_runtime_profile_reward_code_redeem_input( - input.user_id, - input.code, - input.redeemed_at_micros, - ) - .map_err(|error| error.to_string())?; - let redeemed_at = Timestamp::from_micros_since_unix_epoch(validated_input.redeemed_at_micros); - let user_id = validated_input.user_id; - let code = validated_input.code; - let redeem_code = ctx - .db - .profile_redeem_code() - .code() - .find(&code) - .ok_or_else(|| "兑换码不存在".to_string())?; - - let user_used_count = count_profile_redeem_code_user_usage(ctx, &code, &user_id); - validate_runtime_profile_redeem_code_usage( - &build_profile_redeem_code_snapshot_from_row(&redeem_code), - &user_id, - user_used_count, - validated_input.redeemed_at_micros, - ) - .map_err(|error| error.to_string())?; - - let usage_id = build_runtime_profile_redeem_code_usage_id( - &code, - &user_id, - validated_input.redeemed_at_micros, - user_used_count, - ); - let wallet_ledger_id = build_runtime_profile_redeem_code_ledger_id(&usage_id); - let wallet_balance = apply_profile_wallet_delta( - ctx, - &user_id, - redeem_code.reward_points, - RuntimeProfileWalletLedgerSourceType::RedeemCodeReward, - &wallet_ledger_id, - redeemed_at, - )?; - - ctx.db - .profile_redeem_code_usage() - .insert(ProfileRedeemCodeUsage { - usage_id, - code: code.clone(), - user_id, - amount_granted: redeem_code.reward_points, - created_at: redeemed_at, - }); - - let next_code = ProfileRedeemCode { - global_used_count: redeem_code.global_used_count.saturating_add(1), - updated_at: redeemed_at, - ..redeem_code - }; - ctx.db.profile_redeem_code().code().delete(&code); - ctx.db.profile_redeem_code().insert(next_code); - - let ledger_entry = ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&wallet_ledger_id) - .ok_or_else(|| "兑换码钱包流水写入失败".to_string())?; - - Ok(RuntimeProfileRewardCodeRedeemSnapshot { - wallet_balance, - amount_granted: ledger_entry.amount_delta.max(0) as u64, - ledger_entry: build_profile_wallet_ledger_snapshot_from_row(&ledger_entry), - }) -} - -fn admin_upsert_profile_redeem_code_record( - ctx: &ReducerContext, - input: RuntimeProfileRedeemCodeAdminUpsertInput, -) -> Result { - let validated_input = build_runtime_profile_redeem_code_admin_upsert_input( - input.admin_user_id, - input.code, - input.mode, - input.reward_points, - input.max_uses, - input.enabled, - input.allowed_user_ids, - input.allowed_public_user_codes, - input.starts_at_micros, - input.expires_at_micros, - input.updated_at_micros, - ) - .map_err(|error| error.to_string())?; - let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); - let allowed_user_ids = resolve_profile_redeem_code_allowed_user_ids(ctx, &validated_input)?; - let existing = ctx - .db - .profile_redeem_code() - .code() - .find(&validated_input.code); - let action = if existing.is_some() { - "update" - } else { - "create" - }; - let operation_code = validated_input.code.clone(); - let operator_user_id = validated_input.admin_user_id.clone(); - let created_at = existing - .as_ref() - .map(|row| row.created_at) - .unwrap_or(updated_at); - let global_used_count = existing - .as_ref() - .map(|row| row.global_used_count) - .unwrap_or(0); - - if let Some(existing) = existing { - ctx.db.profile_redeem_code().code().delete(&existing.code); - } - - let row = ProfileRedeemCode { - code: validated_input.code, - mode: validated_input.mode, - reward_points: validated_input.reward_points, - max_uses: validated_input.max_uses, - global_used_count, - enabled: validated_input.enabled, - allowed_user_ids, - created_by: validated_input.admin_user_id, - created_at, - updated_at, - starts_at: validated_input - .starts_at_micros - .map(Timestamp::from_micros_since_unix_epoch), - expires_at: validated_input - .expires_at_micros - .map(Timestamp::from_micros_since_unix_epoch), - }; - let inserted = ctx.db.profile_redeem_code().insert(row); - insert_profile_code_operation( - ctx, - "redeem", - &operation_code, - action, - &operator_user_id, - updated_at, - ); - Ok(build_profile_redeem_code_snapshot_from_row(&inserted)) -} - -fn admin_disable_profile_redeem_code_record( - ctx: &ReducerContext, - input: RuntimeProfileRedeemCodeAdminDisableInput, -) -> Result { - let validated_input = build_runtime_profile_redeem_code_admin_disable_input( - input.admin_user_id, - input.code, - input.updated_at_micros, - ) - .map_err(|error| error.to_string())?; - let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); - let existing = ctx - .db - .profile_redeem_code() - .code() - .find(&validated_input.code) - .ok_or_else(|| "兑换码不存在".to_string())?; - - ctx.db.profile_redeem_code().code().delete(&existing.code); - let inserted = ctx.db.profile_redeem_code().insert(ProfileRedeemCode { - enabled: false, - updated_at, - ..existing - }); - insert_profile_code_operation( - ctx, - "redeem", - &validated_input.code, - "disable", - &validated_input.admin_user_id, - updated_at, - ); - Ok(build_profile_redeem_code_snapshot_from_row(&inserted)) -} - -fn admin_upsert_profile_invite_code_record( - ctx: &ReducerContext, - input: RuntimeProfileInviteCodeAdminUpsertInput, -) -> Result { - let validated_input = build_runtime_profile_invite_code_admin_upsert_input( - input.admin_user_id, - input.invite_code, - input.metadata_json, - input.starts_at_micros, - input.expires_at_micros, - input.updated_at_micros, - ) - .map_err(|error| error.to_string())?; - let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); - let user_id = build_admin_profile_invite_code_user_id( - &validated_input.admin_user_id, - &validated_input.invite_code, - ); - let operation_code = validated_input.invite_code.clone(); - let operator_user_id = validated_input.admin_user_id.clone(); - - if let Some(existing) = ctx - .db - .profile_invite_code() - .invite_code() - .find(&validated_input.invite_code) - { - if existing.user_id != user_id { - return Err("邀请码已被其他用户占用".to_string()); - } - ctx.db - .profile_invite_code() - .user_id() - .delete(&existing.user_id); - let inserted = ctx.db.profile_invite_code().insert(ProfileInviteCode { - user_id, - invite_code: validated_input.invite_code, - metadata_json: validated_input.metadata_json, - created_at: existing.created_at, - updated_at, - starts_at: validated_input - .starts_at_micros - .map(Timestamp::from_micros_since_unix_epoch), - expires_at: validated_input - .expires_at_micros - .map(Timestamp::from_micros_since_unix_epoch), - }); - insert_profile_code_operation( - ctx, - "invite", - &operation_code, - "update", - &operator_user_id, - updated_at, - ); - return Ok(build_profile_invite_code_snapshot_from_row(&inserted)); - } - - let inserted = ctx.db.profile_invite_code().insert(ProfileInviteCode { - user_id, - invite_code: validated_input.invite_code, - metadata_json: validated_input.metadata_json, - created_at: updated_at, - updated_at, - starts_at: validated_input - .starts_at_micros - .map(Timestamp::from_micros_since_unix_epoch), - expires_at: validated_input - .expires_at_micros - .map(Timestamp::from_micros_since_unix_epoch), - }); - insert_profile_code_operation( - ctx, - "invite", - &operation_code, - "create", - &operator_user_id, - updated_at, - ); - Ok(build_profile_invite_code_snapshot_from_row(&inserted)) -} - -fn build_profile_referral_invite_center_snapshot( - ctx: &ReducerContext, - user_id: &str, -) -> RuntimeReferralInviteCenterSnapshot { - let code = ensure_profile_invite_code(ctx, user_id); - let today_inviter_reward_count = - count_today_profile_referral_inviter_rewards(ctx, user_id, ctx.timestamp); - let invited_relations = ctx - .db - .profile_referral_relation() - .by_profile_referral_inviter_user_id() - .filter(user_id) - .collect::>(); - let invited_count = invited_relations.len() as u32; - let rewarded_invite_count = invited_relations - .iter() - .filter(|row| row.inviter_reward_granted) - .count() as u32; - let bound_relation = ctx - .db - .profile_referral_relation() - .invitee_user_id() - .find(&user_id.to_string()); - - RuntimeReferralInviteCenterSnapshot { - user_id: user_id.to_string(), - invite_code: code.invite_code.clone(), - invite_link_path: build_runtime_profile_invite_link_path(&code.invite_code), - invited_count, - rewarded_invite_count, - today_inviter_reward_count, - today_inviter_reward_remaining: PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT - .saturating_sub(today_inviter_reward_count), - reward_points: PROFILE_REFERRAL_REWARD_POINTS, - invited_users: list_profile_referral_invited_users(ctx, user_id), - has_redeemed_code: bound_relation.is_some(), - bound_inviter_user_id: bound_relation - .as_ref() - .map(|relation| relation.inviter_user_id.clone()), - bound_at_micros: bound_relation - .as_ref() - .map(|relation| relation.bound_at.to_micros_since_unix_epoch()), - updated_at_micros: code.updated_at.to_micros_since_unix_epoch(), - } -} - -fn list_profile_referral_invited_users( - ctx: &ReducerContext, - inviter_user_id: &str, -) -> Vec { - // 中文注释:邀请面板只展示最近成功邀请用户,完整统计仍由计数字段承担。 - let inviter_user_id = inviter_user_id.to_string(); - let mut relations = ctx - .db - .profile_referral_relation() - .by_profile_referral_inviter_user_id() - .filter(&inviter_user_id) - .collect::>(); - - relations.sort_by(|left, right| { - right - .bound_at - .to_micros_since_unix_epoch() - .cmp(&left.bound_at.to_micros_since_unix_epoch()) - }); - - relations - .into_iter() - .take(PROFILE_REFERRAL_INVITED_USERS_LIMIT) - .map(|relation| { - let account = ctx - .db - .user_account() - .user_id() - .find(&relation.invitee_user_id); - RuntimeReferralInvitedUserSnapshot { - user_id: relation.invitee_user_id, - display_name: account - .as_ref() - .map(|user| user.display_name.trim()) - .filter(|name| !name.is_empty()) - .unwrap_or("玩家") - .to_string(), - avatar_url: account.and_then(|user| user.avatar_url), - bound_at_micros: relation.bound_at.to_micros_since_unix_epoch(), - } - }) - .collect() -} - -fn ensure_profile_invite_code(ctx: &ReducerContext, user_id: &str) -> ProfileInviteCode { - if let Some(row) = ctx - .db - .profile_invite_code() - .user_id() - .find(&user_id.to_string()) - { - return row; - } - - let mut invite_code = build_runtime_profile_invite_code(user_id, 0); - let mut salt = 1; - while ctx - .db - .profile_invite_code() - .invite_code() - .find(&invite_code) - .is_some() - { - invite_code = build_runtime_profile_invite_code(user_id, salt); - salt += 1; - } - - ctx.db.profile_invite_code().insert(ProfileInviteCode { - user_id: user_id.to_string(), - invite_code, - metadata_json: PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(), - created_at: ctx.timestamp, - updated_at: ctx.timestamp, - starts_at: None, - expires_at: None, - }) -} - -fn merge_user_account_tags( - ctx: &ReducerContext, - user_id: &str, - granted_tags: Vec, -) -> Result<(), String> { - let granted_tags = - normalize_profile_user_tags(granted_tags).map_err(|error| error.to_string())?; - if granted_tags.is_empty() { - return Ok(()); - } - - let Some(mut account) = ctx.db.user_account().user_id().find(&user_id.to_string()) else { - return Err("用户不存在".to_string()); - }; - - let mut next_tags = account.user_tags.take().unwrap_or_default(); - next_tags.extend(granted_tags); - account.user_tags = - Some(normalize_profile_user_tags(next_tags).map_err(|error| error.to_string())?); - ctx.db.user_account().user_id().delete(&account.user_id); - ctx.db.user_account().insert(account); - Ok(()) -} - -fn profile_invite_code_metadata_user_tags(metadata_json: &str) -> Result, String> { - let metadata = serde_json::from_str::(metadata_json) - .map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata.to_string())?; - let tags = metadata - .get("userTags") - .or_else(|| metadata.get("user_tags")) - .or_else(|| metadata.get("tags")) - .and_then(JsonValue::as_array) - .map(|items| { - items - .iter() - .filter_map(JsonValue::as_str) - .map(str::to_string) - .collect::>() - }) - .unwrap_or_default(); - normalize_profile_user_tags(tags).map_err(|error| error.to_string()) -} - -fn validate_profile_invite_code_redeem_time( - invite_code: &ProfileInviteCode, - now_micros: i64, -) -> Result<(), String> { - if invite_code - .starts_at - .map(|starts_at| now_micros < starts_at.to_micros_since_unix_epoch()) - .unwrap_or(false) - { - return Err("邀请码未生效".to_string()); - } - - if invite_code - .expires_at - .map(|expires_at| now_micros >= expires_at.to_micros_since_unix_epoch()) - .unwrap_or(false) - { - return Err("邀请码已过期".to_string()); - } - - Ok(()) -} - -fn count_today_profile_referral_inviter_rewards( - ctx: &ReducerContext, - user_id: &str, - now: Timestamp, -) -> u32 { - let day_start_micros = runtime_profile_day_start_micros(now.to_micros_since_unix_epoch()); - ctx.db - .profile_wallet_ledger() - .by_profile_wallet_ledger_user_id() - .filter(user_id) - .filter(|row| { - row.user_id == user_id - && row.source_type == RuntimeProfileWalletLedgerSourceType::InviteInviterReward - && row.created_at.to_micros_since_unix_epoch() >= day_start_micros - }) - .count() as u32 -} - -fn is_admin_profile_invite_code_user_id(user_id: &str) -> bool { - user_id.starts_with("admin:") -} - -fn build_admin_profile_invite_code_user_id(admin_user_id: &str, invite_code: &str) -> String { - format!("admin:{}:{}", admin_user_id, invite_code) -} - -fn profile_wallet_balance(ctx: &ReducerContext, user_id: &str) -> u64 { - ctx.db - .profile_dashboard_state() - .user_id() - .find(&user_id.to_string()) - .map(|row| row.wallet_balance) - .unwrap_or(0) -} - -fn build_profile_wallet_config_snapshot( - ctx: &ReducerContext, -) -> RuntimeProfileWalletConfigSnapshot { - ctx.db - .profile_wallet_config() - .config_id() - .find(&PROFILE_WALLET_CONFIG_GLOBAL_ID.to_string()) - .map(|row| build_profile_wallet_config_snapshot_from_row(&row)) - .unwrap_or_else(|| RuntimeProfileWalletConfigSnapshot { - config_id: PROFILE_WALLET_CONFIG_GLOBAL_ID.to_string(), - initial_mud_points: PROFILE_NEW_USER_INITIAL_WALLET_POINTS, - created_by: String::new(), - created_at_micros: 0, - updated_by: String::new(), - updated_at_micros: 0, - }) -} - -fn profile_new_user_initial_wallet_points(ctx: &ReducerContext) -> u64 { - build_profile_wallet_config_snapshot(ctx).initial_mud_points -} - -fn build_new_user_registration_wallet_ledger_id(user_id: &str) -> String { - format!("{PROFILE_NEW_USER_REGISTRATION_LEDGER_PREFIX}:{user_id}") -} - -fn grant_new_user_registration_wallet_reward_tx( - ctx: &ReducerContext, - input: RuntimeProfileDashboardGetInput, -) -> Result { - let validated_input = build_runtime_profile_dashboard_get_input(input.user_id) - .map_err(|error| error.to_string())?; - let ledger_id = build_new_user_registration_wallet_ledger_id(&validated_input.user_id); - if ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&ledger_id) - .is_none() - { - apply_profile_wallet_delta( - ctx, - &validated_input.user_id, - profile_new_user_initial_wallet_points(ctx), - RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward, - &ledger_id, - ctx.timestamp, - )?; - } - - get_profile_dashboard_snapshot( - ctx, - RuntimeProfileDashboardGetInput { - user_id: validated_input.user_id, - }, - ) -} - -fn build_profile_recharge_center_snapshot( - ctx: &ReducerContext, - user_id: &str, -) -> RuntimeProfileRechargeCenterSnapshot { - ensure_default_profile_recharge_product_config(ctx); - refresh_profile_wallet_expiring_points(ctx, user_id, ctx.timestamp); - let wallet_balance = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&user_id.to_string()) - .map(|row| row.wallet_balance) - .unwrap_or(0); - - let has_points_recharged = has_profile_points_recharged(ctx, user_id); - let mut point_products = Vec::new(); - let mut membership_products = Vec::new(); - for row in profile_recharge_product_config_rows(ctx, false) { - let product = build_profile_recharge_product_snapshot_from_config_row(&row); - match product.kind { - RuntimeProfileRechargeProductKind::Points => { - let has_product_recharged = - has_profile_product_recharged(ctx, user_id, &product.product_id); - point_products.push(resolve_profile_recharge_product_display( - product, - has_product_recharged, - )); - } - RuntimeProfileRechargeProductKind::Membership => { - membership_products.push(product); - } - } - } - - RuntimeProfileRechargeCenterSnapshot { - user_id: user_id.to_string(), - wallet_balance, - membership: build_profile_membership_snapshot(ctx, user_id), - point_products, - membership_products, - benefits: runtime_profile_membership_benefits(), - latest_order: latest_profile_recharge_order(ctx, user_id) - .map(|row| build_profile_recharge_order_snapshot_from_row(&row)), - has_points_recharged, - daily_free_points: build_profile_daily_free_points_snapshot(ctx, user_id, ctx.timestamp), - } -} - -fn get_profile_task_center_snapshot( - ctx: &ReducerContext, - input: RuntimeProfileTaskCenterGetInput, - record_login_event: bool, -) -> Result { - let validated_input = build_runtime_profile_task_center_get_input(input.user_id) - .map_err(|error| error.to_string())?; - ensure_default_profile_task_config(ctx); - - if record_login_event { - record_daily_login_tracking_event(ctx, &validated_input.user_id)?; - } - - build_profile_task_center_snapshot(ctx, &validated_input.user_id, ctx.timestamp) -} - -fn claim_profile_task_reward_record( - ctx: &ReducerContext, - input: RuntimeProfileTaskClaimInput, -) -> Result { - let validated_input = build_runtime_profile_task_claim_input(input.user_id, input.task_id) - .map_err(|error| error.to_string())?; - ensure_default_profile_task_config(ctx); - - let config = ctx - .db - .profile_task_config() - .task_id() - .find(&validated_input.task_id) - .ok_or_else(|| RuntimeProfileFieldError::MissingTaskId.to_string())?; - if !config.enabled { - return Err(RuntimeProfileFieldError::TaskDisabled.to_string()); - } - - if is_daily_login_task_config(&config) { - record_daily_login_tracking_event(ctx, &validated_input.user_id)?; - } - let day_key = runtime_profile_beijing_day_key(ctx.timestamp.to_micros_since_unix_epoch()); - let claim_id = - build_runtime_profile_task_claim_id(&validated_input.user_id, &config.task_id, day_key); - if ctx - .db - .profile_task_reward_claim() - .claim_id() - .find(&claim_id) - .is_some() - { - return Err(RuntimeProfileFieldError::TaskAlreadyClaimed.to_string()); - } - - let progress_count = profile_task_progress_count(ctx, &validated_input.user_id, &config)?; - if progress_count < config.threshold { - return Err(RuntimeProfileFieldError::TaskNotClaimable.to_string()); - } - - let ledger_id = build_runtime_profile_task_reward_ledger_id( - &validated_input.user_id, - &config.task_id, - day_key, - ); - let wallet_balance = grant_profile_wallet_points( - ctx, - &validated_input.user_id, - config.reward_points, - RuntimeProfileWalletLedgerSourceType::DailyTaskReward, - &ledger_id, - ctx.timestamp, - )?; - let claim = ctx - .db - .profile_task_reward_claim() - .insert(ProfileTaskRewardClaim { - claim_id: claim_id.clone(), - user_id: validated_input.user_id.clone(), - task_id: config.task_id.clone(), - day_key, - reward_points: config.reward_points, - wallet_ledger_id: ledger_id.clone(), - claimed_at: ctx.timestamp, - }); - - refresh_profile_task_progress(ctx, &validated_input.user_id, &config, day_key)?; - let ledger_entry = ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&ledger_id) - .ok_or_else(|| "任务奖励钱包流水写入失败".to_string())?; - - Ok(RuntimeProfileTaskClaimSnapshot { - user_id: validated_input.user_id.clone(), - task_id: config.task_id.clone(), - day_key, - reward_points: claim.reward_points, - wallet_balance, - ledger_entry: build_profile_wallet_ledger_snapshot_from_row(&ledger_entry), - center: build_profile_task_center_snapshot(ctx, &validated_input.user_id, ctx.timestamp)?, - }) -} - -fn list_profile_task_config_snapshots( - ctx: &ReducerContext, - input: RuntimeProfileTaskConfigAdminListInput, -) -> Result, String> { - let _validated_input = build_runtime_profile_task_config_admin_list_input(input.admin_user_id) - .map_err(|error| error.to_string())?; - ensure_default_profile_task_config(ctx); - - let mut entries = ctx - .db - .profile_task_config() - .iter() - .map(|row| build_profile_task_config_snapshot_from_row(&row)) - .collect::>(); - entries.sort_by(|left, right| { - left.sort_order - .cmp(&right.sort_order) - .then_with(|| left.task_id.cmp(&right.task_id)) - }); - Ok(entries) -} - -fn get_profile_wallet_config_snapshot( - ctx: &ReducerContext, - input: RuntimeProfileWalletConfigAdminGetInput, -) -> Result { - let _validated_input = build_runtime_profile_wallet_config_admin_get_input(input.admin_user_id) - .map_err(|error| error.to_string())?; - Ok(build_profile_wallet_config_snapshot(ctx)) -} - -fn upsert_profile_wallet_config_record( - ctx: &ReducerContext, - input: RuntimeProfileWalletConfigAdminUpsertInput, -) -> Result { - let validated_input = build_runtime_profile_wallet_config_admin_upsert_input( - input.admin_user_id, - input.initial_mud_points, - input.updated_at_micros, - ) - .map_err(|error| error.to_string())?; - let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); - let config_id = PROFILE_WALLET_CONFIG_GLOBAL_ID.to_string(); - let existing = ctx.db.profile_wallet_config().config_id().find(&config_id); - if let Some(row) = existing.as_ref() { - ctx.db - .profile_wallet_config() - .config_id() - .delete(&row.config_id); - } - let inserted = ctx.db.profile_wallet_config().insert(ProfileWalletConfig { - config_id, - initial_mud_points: validated_input.initial_mud_points, - created_by: existing - .as_ref() - .map(|row| row.created_by.clone()) - .unwrap_or_else(|| validated_input.admin_user_id.clone()), - created_at: existing - .as_ref() - .map(|row| row.created_at) - .unwrap_or(updated_at), - updated_by: validated_input.admin_user_id, - updated_at, - }); - Ok(build_profile_wallet_config_snapshot_from_row(&inserted)) -} - -fn list_profile_recharge_product_config_snapshots( - ctx: &ReducerContext, - input: RuntimeProfileRechargeProductAdminListInput, -) -> Result, String> { - let _validated_input = - build_runtime_profile_recharge_product_admin_list_input(input.admin_user_id) - .map_err(|error| error.to_string())?; - ensure_default_profile_recharge_product_config(ctx); - - Ok(profile_recharge_product_config_rows(ctx, true) - .iter() - .map(build_profile_recharge_product_config_snapshot_from_row) - .collect()) -} - -fn admin_list_profile_redeem_code_records( - ctx: &ReducerContext, - input: RuntimeProfileRedeemCodeAdminListInput, -) -> Result< - ( - Vec, - Vec, - ), - String, -> { - let _validated_input = build_runtime_profile_redeem_code_admin_list_input(input.admin_user_id) - .map_err(|error| error.to_string())?; - - let mut entries = ctx - .db - .profile_redeem_code() - .iter() - .map(|row| build_profile_redeem_code_snapshot_from_row(&row)) - .collect::>(); - entries.sort_by(|left, right| { - right - .updated_at_micros - .cmp(&left.updated_at_micros) - .then_with(|| left.code.cmp(&right.code)) - }); - Ok((entries, profile_code_operation_snapshots(ctx, "redeem"))) -} - -fn admin_list_profile_invite_code_records( - ctx: &ReducerContext, - input: RuntimeProfileInviteCodeAdminListInput, -) -> Result< - ( - Vec, - Vec, - ), - String, -> { - let _validated_input = build_runtime_profile_invite_code_admin_list_input(input.admin_user_id) - .map_err(|error| error.to_string())?; - - let mut entries = ctx - .db - .profile_invite_code() - .iter() - .filter(|row| is_admin_profile_invite_code_user_id(&row.user_id)) - .map(|row| build_profile_invite_code_snapshot_from_row(&row)) - .collect::>(); - entries.sort_by(|left, right| { - right - .updated_at_micros - .cmp(&left.updated_at_micros) - .then_with(|| left.invite_code.cmp(&right.invite_code)) - }); - Ok((entries, profile_code_operation_snapshots(ctx, "invite"))) -} - -fn upsert_profile_recharge_product_config_record( - ctx: &ReducerContext, - input: RuntimeProfileRechargeProductAdminUpsertInput, -) -> Result { - let validated_input = build_runtime_profile_recharge_product_admin_upsert_input( - input.admin_user_id, - input.product_id, - input.title, - input.price_cents, - input.kind, - input.points_amount, - input.bonus_points, - input.duration_days, - input.badge_label, - input.description, - input.tier, - input.membership_period_points, - input.membership_period_days, - input.membership_queue_limit, - input.membership_discount_bps, - input.enabled, - input.sort_order, - input.updated_at_micros, - ) - .map_err(|error| error.to_string())?; - ensure_default_profile_recharge_product_config(ctx); - - let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); - let existing = ctx - .db - .profile_recharge_product_config() - .product_id() - .find(&validated_input.product_id); - if let Some(row) = existing.as_ref() { - ctx.db - .profile_recharge_product_config() - .product_id() - .delete(&row.product_id); - } - - let inserted = ctx - .db - .profile_recharge_product_config() - .insert(ProfileRechargeProductConfig { - product_id: validated_input.product_id, - title: validated_input.title, - price_cents: validated_input.price_cents, - kind: validated_input.kind, - points_amount: validated_input.points_amount, - bonus_points: validated_input.bonus_points, - duration_days: validated_input.duration_days, - badge_label: validated_input.badge_label, - description: validated_input.description, - tier: validated_input.tier, - enabled: validated_input.enabled, - sort_order: validated_input.sort_order, - created_by: existing - .as_ref() - .map(|row| row.created_by.clone()) - .unwrap_or_else(|| validated_input.admin_user_id.clone()), - created_at: existing - .as_ref() - .map(|row| row.created_at) - .unwrap_or(updated_at), - updated_by: validated_input.admin_user_id, - updated_at, - membership_period_points: validated_input.membership_period_points, - membership_period_days: validated_input.membership_period_days, - membership_queue_limit: validated_input.membership_queue_limit, - membership_discount_bps: validated_input.membership_discount_bps, - }); - Ok(build_profile_recharge_product_config_snapshot_from_row( - &inserted, - )) -} - -fn upsert_profile_task_config_record( - ctx: &ReducerContext, - input: RuntimeProfileTaskConfigAdminUpsertInput, -) -> Result { - let validated_input = build_runtime_profile_task_config_admin_upsert_input( - input.admin_user_id, - input.task_id, - input.title, - input.description, - input.event_key, - input.cycle, - input.scope_kind, - input.threshold, - input.reward_points, - input.enabled, - input.sort_order, - input.updated_at_micros, - ) - .map_err(|error| error.to_string())?; - let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); - let existing = ctx - .db - .profile_task_config() - .task_id() - .find(&validated_input.task_id); - if let Some(row) = existing.as_ref() { - ctx.db.profile_task_config().task_id().delete(&row.task_id); - } - - let inserted = ctx.db.profile_task_config().insert(ProfileTaskConfig { - task_id: validated_input.task_id, - title: validated_input.title, - description: validated_input.description, - event_key: validated_input.event_key, - cycle: validated_input.cycle, - scope_kind: validated_input.scope_kind, - threshold: validated_input.threshold, - reward_points: validated_input.reward_points, - enabled: validated_input.enabled, - sort_order: validated_input.sort_order, - created_by: existing - .as_ref() - .map(|row| row.created_by.clone()) - .unwrap_or_else(|| validated_input.admin_user_id.clone()), - created_at: existing - .as_ref() - .map(|row| row.created_at) - .unwrap_or(updated_at), - updated_by: validated_input.admin_user_id, - updated_at, - }); - Ok(build_profile_task_config_snapshot_from_row(&inserted)) -} - -fn disable_profile_task_config_record( - ctx: &ReducerContext, - input: RuntimeProfileTaskConfigAdminDisableInput, -) -> Result { - let validated_input = build_runtime_profile_task_config_admin_disable_input( - input.admin_user_id, - input.task_id, - input.updated_at_micros, - ) - .map_err(|error| error.to_string())?; - let row = ctx - .db - .profile_task_config() - .task_id() - .find(&validated_input.task_id) - .ok_or_else(|| RuntimeProfileFieldError::MissingTaskId.to_string())?; - let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); - ctx.db.profile_task_config().task_id().delete(&row.task_id); - let inserted = ctx.db.profile_task_config().insert(ProfileTaskConfig { - enabled: false, - updated_by: validated_input.admin_user_id, - updated_at, - ..row - }); - Ok(build_profile_task_config_snapshot_from_row(&inserted)) -} - -fn build_profile_task_center_snapshot( - ctx: &ReducerContext, - user_id: &str, - updated_at: Timestamp, -) -> Result { - ensure_default_profile_task_config(ctx); - refresh_profile_wallet_expiring_points(ctx, user_id, updated_at); - let day_key = runtime_profile_beijing_day_key(updated_at.to_micros_since_unix_epoch()); - let mut configs = ctx.db.profile_task_config().iter().collect::>(); - configs.sort_by(|left, right| { - left.sort_order - .cmp(&right.sort_order) - .then_with(|| left.task_id.cmp(&right.task_id)) - }); - let mut tasks = Vec::with_capacity(configs.len()); - for config in configs { - validate_profile_task_user_scope(&config)?; - let progress_count = profile_task_progress_count(ctx, user_id, &config)?; - refresh_profile_task_progress(ctx, user_id, &config, day_key)?; - let claim = ctx.db.profile_task_reward_claim().claim_id().find( - &build_runtime_profile_task_claim_id(user_id, &config.task_id, day_key), - ); - tasks.push(RuntimeProfileTaskItemSnapshot { - task_id: config.task_id, - title: config.title, - description: config.description, - event_key: config.event_key, - cycle: config.cycle, - threshold: config.threshold, - progress_count, - reward_points: config.reward_points, - status: resolve_runtime_profile_task_status( - config.enabled, - progress_count, - config.threshold, - claim.is_some(), - ), - day_key, - claimed_at_micros: claim.map(|row| row.claimed_at.to_micros_since_unix_epoch()), - updated_at_micros: updated_at.to_micros_since_unix_epoch(), - }); - } - - Ok(RuntimeProfileTaskCenterSnapshot { - user_id: user_id.to_string(), - day_key, - wallet_balance: profile_wallet_balance(ctx, user_id), - tasks, - updated_at_micros: updated_at.to_micros_since_unix_epoch(), - }) -} - -fn query_analytics_metric_buckets( - ctx: &ReducerContext, - input: AnalyticsMetricQueryInput, -) -> Result, String> { - let validated_input = build_analytics_metric_query_input( - input.event_key, - input.scope_kind, - input.scope_id, - input.granularity, - ) - .map_err(|error| error.to_string())?; - let stats = ctx - .db - .tracking_daily_stat() - .by_tracking_daily_stat_scope_day() - .filter(( - validated_input.scope_kind, - validated_input.scope_id.as_str(), - )) - .filter(|row| { - row.event_key.trim() == validated_input.event_key - && row.scope_kind == validated_input.scope_kind - && row.scope_id.trim() == validated_input.scope_id - }) - .map(|row| RuntimeAnalyticsDailyStatSnapshot { - event_key: row.event_key, - scope_kind: row.scope_kind, - scope_id: row.scope_id, - day_key: row.day_key, - count: row.count, - }) - .collect::>(); - - Ok(aggregate_runtime_tracking_daily_stats( - stats, - &validated_input.event_key, - validated_input.scope_kind, - &validated_input.scope_id, - validated_input.granularity, - )) -} - -fn refresh_profile_task_progress( - ctx: &ReducerContext, - user_id: &str, - config: &ProfileTaskConfig, - day_key: i64, -) -> Result { - let progress_id = build_runtime_profile_task_progress_id(user_id, &config.task_id, day_key); - if let Some(existing) = ctx - .db - .profile_task_progress() - .progress_id() - .find(&progress_id) - { - ctx.db - .profile_task_progress() - .progress_id() - .delete(&existing.progress_id); - } - let progress_count = profile_task_progress_count(ctx, user_id, config)?; - let claimed = ctx - .db - .profile_task_reward_claim() - .claim_id() - .find(&build_runtime_profile_task_claim_id( - user_id, - &config.task_id, - day_key, - )) - .is_some(); - Ok(ctx.db.profile_task_progress().insert(ProfileTaskProgress { - progress_id, - user_id: user_id.to_string(), - task_id: config.task_id.clone(), - day_key, - progress_count, - threshold: config.threshold, - status: resolve_runtime_profile_task_status( - config.enabled, - progress_count, - config.threshold, - claimed, - ), - updated_at: ctx.timestamp, - })) -} - -fn profile_task_progress_count( - ctx: &ReducerContext, - user_id: &str, - config: &ProfileTaskConfig, -) -> Result { - validate_profile_task_user_scope(config)?; - let day_key = runtime_profile_beijing_day_key(ctx.timestamp.to_micros_since_unix_epoch()); - let scope_id = profile_task_tracking_scope_id(user_id, config)?; - Ok(ctx - .db - .tracking_daily_stat() - .stat_id() - .find(&build_runtime_tracking_daily_stat_id( - &config.event_key, - config.scope_kind, - &scope_id, - day_key, - )) - .map(|row| row.count) - .unwrap_or(0)) -} - -fn profile_task_tracking_scope_id( - user_id: &str, - config: &ProfileTaskConfig, -) -> Result { - validate_profile_task_user_scope(config)?; - Ok(user_id.to_string()) -} - -fn insert_profile_code_operation( - ctx: &ReducerContext, - code_kind: &str, - code: &str, - action: &str, - operator_user_id: &str, - created_at: Timestamp, -) { - let created_at_micros = created_at.to_micros_since_unix_epoch(); - let operation_index = ctx - .db - .profile_code_operation() - .by_profile_code_operation_kind_code() - .filter((code_kind, code)) - .count(); - ctx.db - .profile_code_operation() - .insert(ProfileCodeOperation { - operation_id: format!( - "{}:{}:{}:{}:{}", - code_kind.trim(), - code.trim(), - action.trim(), - created_at_micros, - operation_index - ), - code_kind: code_kind.to_string(), - code: code.to_string(), - action: action.to_string(), - operator_user_id: operator_user_id.to_string(), - created_at, - }); -} - -fn profile_code_operation_snapshots( - ctx: &ReducerContext, - code_kind: &str, -) -> Vec { - let mut entries = ctx - .db - .profile_code_operation() - .by_profile_code_operation_code_kind() - .filter(code_kind) - .map(|row| build_profile_code_operation_snapshot_from_row(&row)) - .collect::>(); - entries.sort_by(|left, right| { - right - .created_at_micros - .cmp(&left.created_at_micros) - .then_with(|| left.code.cmp(&right.code)) - .then_with(|| left.operation_id.cmp(&right.operation_id)) - }); - entries -} - -fn validate_profile_task_user_scope(config: &ProfileTaskConfig) -> Result<(), String> { - if config.scope_kind == RuntimeTrackingScopeKind::User { - Ok(()) - } else { - Err(format!( - "个人任务 scope_kind 首版仅支持 user,当前 task_id={} scope_kind={}", - config.task_id, - config.scope_kind.as_str() - )) - } -} - -fn is_daily_login_task_config(config: &ProfileTaskConfig) -> bool { - config.task_id == PROFILE_TASK_ID_DAILY_LOGIN - && config.event_key == PROFILE_TASK_EVENT_KEY_DAILY_LOGIN - && config.scope_kind == RuntimeTrackingScopeKind::User -} - -fn record_daily_login_tracking_event(ctx: &ReducerContext, user_id: &str) -> Result<(), String> { - let day_key = runtime_profile_beijing_day_key(ctx.timestamp.to_micros_since_unix_epoch()); - let event_id = format!( - "{}:{}:{}", - PROFILE_TASK_LOGIN_EVENT_ID_PREFIX, - user_id.trim(), - day_key - ); - if ctx.db.tracking_event().event_id().find(&event_id).is_some() { - return Ok(()); - } - - record_tracking_event( - ctx, - RuntimeTrackingEventInput { - event_id, - event_key: PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(), - scope_kind: RuntimeTrackingScopeKind::User, - scope_id: user_id.to_string(), - user_id: Some(user_id.to_string()), - owner_user_id: None, - profile_id: None, - module_key: Some(PROFILE_TRACKING_PROFILE_MODULE_KEY.to_string()), - metadata_json: PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(), - occurred_at_micros: ctx.timestamp.to_micros_since_unix_epoch(), - }, - ) -} - -fn should_skip_existing_tracking_event_id(event_exists: bool) -> bool { - event_exists -} - -fn record_tracking_event( - ctx: &ReducerContext, - input: RuntimeTrackingEventInput, -) -> Result<(), String> { - let validated_input = build_runtime_tracking_event_input( - input.event_id, - input.event_key, - input.scope_kind, - input.scope_id, - input.user_id, - input.owner_user_id, - input.profile_id, - input.module_key, - input.metadata_json, - input.occurred_at_micros, - ) - .map_err(|error| error.to_string())?; - let occurred_at = Timestamp::from_micros_since_unix_epoch(validated_input.occurred_at_micros); - let day_key = runtime_profile_beijing_day_key(validated_input.occurred_at_micros); - if should_skip_existing_tracking_event_id( - ctx.db - .tracking_event() - .event_id() - .find(&validated_input.event_id) - .is_some(), - ) { - return Ok(()); - } - // 中文注释:埋点事实与日期维表使用同一北京时间业务日桶,先幂等补齐维表,避免后续周/月/季/年聚合缺少 bucket 映射。 - ensure_analytics_date_dimension_row(ctx, day_key)?; - ctx.db.tracking_event().insert(TrackingEvent { - event_id: validated_input.event_id, - event_key: validated_input.event_key.clone(), - scope_kind: validated_input.scope_kind, - scope_id: validated_input.scope_id.clone(), - day_key, - user_id: validated_input.user_id, - owner_user_id: validated_input.owner_user_id, - profile_id: validated_input.profile_id, - module_key: validated_input.module_key, - metadata_json: validated_input.metadata_json, - occurred_at, - }); - upsert_tracking_daily_stat( - ctx, - &validated_input.event_key, - validated_input.scope_kind, - &validated_input.scope_id, - day_key, - occurred_at, - ); - Ok(()) -} - -fn upsert_tracking_daily_stat( - ctx: &ReducerContext, - event_key: &str, - scope_kind: RuntimeTrackingScopeKind, - scope_id: &str, - day_key: i64, - occurred_at: Timestamp, -) { - let stat_id = build_runtime_tracking_daily_stat_id(event_key, scope_kind, scope_id, day_key); - let existing = ctx.db.tracking_daily_stat().stat_id().find(&stat_id); - if let Some(row) = existing { - ctx.db.tracking_daily_stat().stat_id().delete(&row.stat_id); - ctx.db.tracking_daily_stat().insert(TrackingDailyStat { - stat_id, - event_key: row.event_key, - scope_kind: row.scope_kind, - scope_id: row.scope_id, - day_key: row.day_key, - count: row.count.saturating_add(1), - first_occurred_at: row.first_occurred_at, - last_occurred_at: occurred_at, - updated_at: occurred_at, - }); - } else { - ctx.db.tracking_daily_stat().insert(TrackingDailyStat { - stat_id, - event_key: event_key.to_string(), - scope_kind, - scope_id: scope_id.to_string(), - day_key, - count: 1, - first_occurred_at: occurred_at, - last_occurred_at: occurred_at, - updated_at: occurred_at, - }); - } -} - -fn ensure_default_profile_task_config(ctx: &ReducerContext) -> ProfileTaskConfig { - if let Some(row) = ctx - .db - .profile_task_config() - .task_id() - .find(&PROFILE_TASK_ID_DAILY_LOGIN.to_string()) - { - return row; - } - - let default_config = build_default_runtime_profile_task_config( - ctx.timestamp.to_micros_since_unix_epoch(), - PROFILE_TASK_SYSTEM_USER_ID.to_string(), - ); - ctx.db.profile_task_config().insert(ProfileTaskConfig { - task_id: default_config.task_id, - title: default_config.title, - description: default_config.description, - event_key: default_config.event_key, - cycle: default_config.cycle, - scope_kind: default_config.scope_kind, - threshold: default_config.threshold, - reward_points: default_config.reward_points, - enabled: default_config.enabled, - sort_order: default_config.sort_order, - created_by: default_config.created_by, - created_at: ctx.timestamp, - updated_by: default_config.updated_by, - updated_at: ctx.timestamp, - }) -} - -fn ensure_default_profile_recharge_product_config(ctx: &ReducerContext) { - if ctx.db.profile_recharge_product_config().count() > 0 { - migrate_legacy_default_profile_recharge_product_config(ctx); - return; - } - - let now = ctx.timestamp; - for (sort_order, product) in runtime_profile_recharge_point_products() - .into_iter() - .chain(runtime_profile_recharge_membership_products()) - .enumerate() - { - ctx.db - .profile_recharge_product_config() - .insert(ProfileRechargeProductConfig { - product_id: product.product_id, - title: product.title, - price_cents: product.price_cents, - kind: product.kind, - points_amount: product.points_amount, - bonus_points: product.bonus_points, - duration_days: product.duration_days, - badge_label: product.badge_label, - description: product.description, - tier: product.tier, - enabled: true, - sort_order: sort_order as i32, - created_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(), - created_at: now, - updated_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(), - updated_at: now, - membership_period_points: product.membership_period_points, - membership_period_days: product.membership_period_days, - membership_queue_limit: product.membership_queue_limit, - membership_discount_bps: product.membership_discount_bps, - }); - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct DefaultPointProductMigration { - bonus_points: u64, - badge_label: &'static str, - description: &'static str, - enabled: bool, -} - -fn resolve_default_point_product_migration( - row: &ProfileRechargeProductConfig, -) -> Option { - if row.kind != RuntimeProfileRechargeProductKind::Points - || row.tier != RuntimeProfileMembershipTier::Normal - || row.updated_by != PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID - || !row.enabled - || row.duration_days != 0 - || row.bonus_points != row.points_amount - || row.badge_label != "首充双倍" - || row.description != format!("首充送{}泥点", row.points_amount) - { - return None; - } - - let expected_product_id = format!("points_{}", row.points_amount); - let expected_title = format!("{}泥点", row.points_amount); - if row.product_id != expected_product_id - || row.title != expected_title - || row.price_cents != row.points_amount.saturating_mul(10) - { - return None; - } - - match row.points_amount { - 60 => Some(DefaultPointProductMigration { - bonus_points: 0, - badge_label: "", - description: "60泥点", - enabled: true, - }), - 180 => Some(DefaultPointProductMigration { - bonus_points: 90, - badge_label: "首充加赠", - description: "首充加赠90泥点", - enabled: true, - }), - 300 => Some(DefaultPointProductMigration { - bonus_points: 150, - badge_label: "首充加赠", - description: "首充加赠150泥点", - enabled: true, - }), - 680 => Some(DefaultPointProductMigration { - bonus_points: 340, - badge_label: "首充加赠", - description: "首充加赠340泥点", - enabled: true, - }), - 1_280 | 3_280 => Some(DefaultPointProductMigration { - bonus_points: row.bonus_points, - badge_label: "首充双倍", - description: "", - enabled: false, - }), - _ => None, - } -} - -fn migrate_legacy_default_profile_recharge_product_config(ctx: &ReducerContext) { - let rows = ctx - .db - .profile_recharge_product_config() - .iter() - .collect::>(); - for mut row in rows { - let Some(migration) = resolve_default_point_product_migration(&row) else { - continue; - }; - row.bonus_points = migration.bonus_points; - row.badge_label = migration.badge_label.to_string(); - if !migration.description.is_empty() { - row.description = migration.description.to_string(); - } - row.enabled = migration.enabled; - row.updated_at = ctx.timestamp; - ctx.db - .profile_recharge_product_config() - .product_id() - .update(row); - } -} - -fn profile_recharge_product_config_rows( - ctx: &ReducerContext, - include_disabled: bool, -) -> Vec { - ensure_default_profile_recharge_product_config(ctx); - let mut rows = ctx - .db - .profile_recharge_product_config() - .iter() - .filter(|row| include_disabled || row.enabled) - .collect::>(); - rows.sort_by(|left, right| { - left.sort_order - .cmp(&right.sort_order) - .then_with(|| left.product_id.cmp(&right.product_id)) - }); - rows -} - -fn profile_recharge_product_by_id( - ctx: &ReducerContext, - product_id: &str, -) -> Option { - ensure_default_profile_recharge_product_config(ctx); - ctx.db - .profile_recharge_product_config() - .product_id() - .find(&product_id.to_string()) - .map(|row| build_profile_recharge_product_snapshot_from_config_row(&row)) -} - -fn enabled_profile_recharge_product_by_id( - ctx: &ReducerContext, - product_id: &str, -) -> Option { - ensure_default_profile_recharge_product_config(ctx); - ctx.db - .profile_recharge_product_config() - .product_id() - .find(&product_id.to_string()) - .filter(|row| row.enabled) - .map(|row| build_profile_recharge_product_snapshot_from_config_row(&row)) -} - -fn resolve_profile_recharge_product_display( - mut product: RuntimeProfileRechargeProductSnapshot, - has_product_recharged: bool, -) -> RuntimeProfileRechargeProductSnapshot { - if product.kind == RuntimeProfileRechargeProductKind::Points && has_product_recharged { - product.bonus_points = 0; - product.badge_label.clear(); - product.description = product.title.clone(); - } - product -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct ProfileMembershipPurchaseResult { - expires_at: Timestamp, - period_points_delta: u64, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct MembershipCyclePointMutation { - points: u64, - cycle_resets_at_micros: Option, -} - -impl MembershipCyclePointMutation { - fn none() -> Self { - Self { - points: 0, - cycle_resets_at_micros: None, - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct DailyFreePointMutation { - points: u64, - day_key: Option, -} - -impl DailyFreePointMutation { - fn none() -> Self { - Self { - points: 0, - day_key: None, - } - } -} - -struct MembershipCycleInitialization { - row: ProfileMembership, - granted_points_delta: u64, - cycle_resets_at: Timestamp, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ActiveMembershipPurchaseMode { - Renew, - Upgrade, -} - -fn normalized_membership_period_days(period_days: u32) -> u32 { - if period_days == 0 { - PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS - } else { - period_days - } -} - -fn membership_period_micros(period_days: u32) -> i64 { - i64::from(normalized_membership_period_days(period_days)) - .saturating_mul(PROFILE_RUNTIME_DAY_MICROS) -} - -fn membership_cycle_reset_at(started_at: Timestamp, period_days: u32) -> Timestamp { - Timestamp::from_micros_since_unix_epoch( - started_at - .to_micros_since_unix_epoch() - .saturating_add(membership_period_micros(period_days)), - ) -} - -fn membership_product_period_points(product: &RuntimeProfileRechargeProductSnapshot) -> u64 { - if product.membership_period_points == 0 { - runtime_profile_membership_period_points(product.tier) - } else { - product.membership_period_points - } -} - -fn membership_product_period_days(product: &RuntimeProfileRechargeProductSnapshot) -> u32 { - normalized_membership_period_days(product.membership_period_days) -} - -fn active_membership_row_at(row: &ProfileMembership, at: Timestamp) -> bool { - row.expires_at.to_micros_since_unix_epoch() > at.to_micros_since_unix_epoch() -} - -fn canonical_membership_pricing_tier( - tier: RuntimeProfileMembershipTier, -) -> RuntimeProfileMembershipTier { - match tier { - RuntimeProfileMembershipTier::Month => RuntimeProfileMembershipTier::Starter, - RuntimeProfileMembershipTier::Season => RuntimeProfileMembershipTier::Basic, - RuntimeProfileMembershipTier::Year => RuntimeProfileMembershipTier::Pro, - _ => tier, - } -} - -fn is_legacy_membership_tier(tier: RuntimeProfileMembershipTier) -> bool { - matches!( - tier, - RuntimeProfileMembershipTier::Month - | RuntimeProfileMembershipTier::Season - | RuntimeProfileMembershipTier::Year - ) -} - -fn active_membership_tier_rank(tier: RuntimeProfileMembershipTier) -> u8 { - runtime_profile_membership_tier_rank(tier).unwrap_or(0) -} - -fn resolve_active_membership_purchase_mode( - current_tier: RuntimeProfileMembershipTier, - next_tier: RuntimeProfileMembershipTier, -) -> Result { - if current_tier == next_tier { - return Ok(ActiveMembershipPurchaseMode::Renew); - } - - let current_rank = active_membership_tier_rank(current_tier); - let next_rank = active_membership_tier_rank(next_tier); - let is_legacy_equivalent_migration = current_rank == next_rank - && is_legacy_membership_tier(current_tier) - && canonical_membership_pricing_tier(current_tier) == next_tier; - - if is_legacy_equivalent_migration { - return Ok(ActiveMembershipPurchaseMode::Renew); - } - - if next_rank > current_rank { - return Ok(ActiveMembershipPurchaseMode::Upgrade); - } - - Err("当前会员有效期内不能购买更低档会员".to_string()) -} - -fn membership_upgrade_amount_cents(target_price_cents: u64, current_price_cents: u64) -> u64 { - target_price_cents.saturating_sub(current_price_cents) -} - -fn membership_product_price_cents_by_tier( - ctx: &ReducerContext, - tier: RuntimeProfileMembershipTier, -) -> Option { - let canonical_tier = canonical_membership_pricing_tier(tier); - profile_recharge_product_config_rows(ctx, false) - .into_iter() - .find(|row| { - row.kind == RuntimeProfileRechargeProductKind::Membership - && canonical_membership_pricing_tier(row.tier) == canonical_tier - }) - .map(|row| row.price_cents) - .or_else(|| { - runtime_profile_recharge_membership_products() - .into_iter() - .find(|product| canonical_membership_pricing_tier(product.tier) == canonical_tier) - .map(|product| product.price_cents) - }) -} - -fn resolve_profile_membership_order_amount_cents( - ctx: &ReducerContext, - user_id: &str, - product: &RuntimeProfileRechargeProductSnapshot, - created_at: Timestamp, -) -> Result { - let Some(row) = ctx - .db - .profile_membership() - .user_id() - .find(&user_id.to_string()) - .filter(|row| active_membership_row_at(row, created_at)) - else { - return Ok(product.price_cents); - }; - - let purchase_mode = resolve_active_membership_purchase_mode(row.tier, product.tier)?; - if purchase_mode == ActiveMembershipPurchaseMode::Renew { - return Ok(product.price_cents); - } - - let current_price_cents = membership_product_price_cents_by_tier(ctx, row.tier).unwrap_or(0); - Ok(membership_upgrade_amount_cents( - product.price_cents, - current_price_cents, - )) -} - -fn resolve_profile_recharge_order_amount_cents( - ctx: &ReducerContext, - user_id: &str, - product: &RuntimeProfileRechargeProductSnapshot, - created_at: Timestamp, -) -> Result { - match product.kind { - RuntimeProfileRechargeProductKind::Points => Ok(product.price_cents), - RuntimeProfileRechargeProductKind::Membership => { - resolve_profile_membership_order_amount_cents(ctx, user_id, product, created_at) - } - } -} - -fn apply_active_membership_upgrade_row( - mut row: ProfileMembership, - tier: RuntimeProfileMembershipTier, - period_days: u32, - period_points: u64, - updated_at: Timestamp, -) -> (ProfileMembership, u64) { - let period_points_delta = period_points.saturating_sub(row.cycle_granted_points); - row.tier = tier; - row.cycle_granted_points = row.cycle_granted_points.max(period_points); - row.cycle_remaining_points = row - .cycle_remaining_points - .saturating_add(period_points_delta); - row.cycle_period_days = period_days; - row.updated_at = updated_at; - (row, period_points_delta) -} - -fn apply_active_membership_renew_row( - mut row: ProfileMembership, - tier: RuntimeProfileMembershipTier, - duration_days: u32, - purchased_at: Timestamp, -) -> ProfileMembership { - row.tier = tier; - row.expires_at = Timestamp::from_micros_since_unix_epoch( - row.expires_at - .to_micros_since_unix_epoch() - .saturating_add(i64::from(duration_days).saturating_mul(PROFILE_RUNTIME_DAY_MICROS)), - ); - row.updated_at = purchased_at; - row -} - -fn upsert_profile_membership_row(ctx: &ReducerContext, row: ProfileMembership) { - ctx.db.profile_membership().user_id().delete(&row.user_id); - ctx.db.profile_membership().insert(row); -} - -fn initialize_missing_profile_membership_cycle( - mut row: ProfileMembership, - now: Timestamp, -) -> Option { - if row.cycle_resets_at.is_some() { - return None; - } - - let period_days = normalized_membership_period_days(row.cycle_period_days); - let period_micros = membership_period_micros(period_days); - let mut cycle_started_at = row.cycle_started_at.unwrap_or(row.started_at); - let mut next_reset_at = membership_cycle_reset_at(cycle_started_at, period_days); - let now_micros = now.to_micros_since_unix_epoch(); - let mut guard = 0u32; - while next_reset_at.to_micros_since_unix_epoch() <= now_micros && guard < 120 { - cycle_started_at = next_reset_at; - next_reset_at = Timestamp::from_micros_since_unix_epoch( - next_reset_at - .to_micros_since_unix_epoch() - .saturating_add(period_micros), - ); - guard = guard.saturating_add(1); - } - - let granted_points = - runtime_profile_membership_period_points(row.tier).max(row.cycle_granted_points); - let granted_points_delta = granted_points.saturating_sub(row.cycle_remaining_points); - row.cycle_started_at = Some(cycle_started_at); - row.cycle_resets_at = Some(next_reset_at); - row.cycle_granted_points = granted_points; - row.cycle_remaining_points = granted_points; - row.cycle_period_days = period_days; - row.updated_at = now; - - Some(MembershipCycleInitialization { - row, - granted_points_delta, - cycle_resets_at: next_reset_at, - }) -} - -fn membership_cycle_metadata( - action: &str, - expired_points: u64, - granted_points: u64, - cycle_resets_at: Option, -) -> String { - serde_json::to_string(&json!({ - "action": action, - "expiredMembershipPeriodPoints": expired_points, - "grantedMembershipPeriodPoints": granted_points, - "cycleResetsAtMicros": cycle_resets_at - .map(|value| value.to_micros_since_unix_epoch()), - })) - .unwrap_or_else(|_| PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()) -} - -fn update_profile_wallet_balance_for_expiring_points( - ctx: &ReducerContext, - user_id: &str, - expired_points: u64, - granted_points: u64, - source_type: RuntimeProfileWalletLedgerSourceType, - ledger_id: &str, - created_at: Timestamp, - metadata_json: String, -) { - if expired_points == 0 && granted_points == 0 { - return; - } - if ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&ledger_id.to_string()) - .is_some() - { - return; - } - - let current = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&user_id.to_string()); - let previous_balance = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0); - let after_expiry = previous_balance.saturating_sub(expired_points); - let next_balance = after_expiry.saturating_add(granted_points); - let created_state_at = current - .as_ref() - .map(|row| row.created_at) - .unwrap_or(created_at); - - if let Some(existing) = current { - ctx.db - .profile_dashboard_state() - .user_id() - .delete(&existing.user_id); - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: user_id.to_string(), - wallet_balance: next_balance, - total_play_time_ms: existing.total_play_time_ms, - created_at: existing.created_at, - updated_at: created_at, - }); - } else { - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: user_id.to_string(), - wallet_balance: next_balance, - total_play_time_ms: 0, - created_at: created_state_at, - updated_at: created_at, - }); - } - - ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger { - wallet_ledger_id: ledger_id.to_string(), - user_id: user_id.to_string(), - amount_delta: next_balance as i64 - previous_balance as i64, - balance_after: next_balance, - source_type, - created_at, - metadata_json: Some(metadata_json), - }); -} - -fn daily_free_points_metadata( - action: &str, - expired_points: u64, - granted_points: u64, - day_key: i64, -) -> String { - serde_json::to_string(&json!({ - "action": action, - "dailyFreeDayKey": day_key, - "expiredDailyFreePoints": expired_points, - "grantedDailyFreePoints": granted_points, - })) - .unwrap_or_else(|_| PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct DailyFreeRefreshPlan { - expired_points: u64, - granted_points: u64, - reset: bool, -} - -fn resolve_daily_free_refresh_plan( - current_day_key: Option, - current_remaining_points: u64, - day_key: i64, -) -> Option { - match current_day_key { - Some(current_day_key) if current_day_key >= day_key => None, - Some(_) => Some(DailyFreeRefreshPlan { - expired_points: current_remaining_points, - granted_points: PROFILE_DAILY_FREE_POINTS_PER_DAY, - reset: true, - }), - None => Some(DailyFreeRefreshPlan { - expired_points: 0, - granted_points: PROFILE_DAILY_FREE_POINTS_PER_DAY, - reset: false, - }), - } -} - -fn refresh_profile_daily_free_points(ctx: &ReducerContext, user_id: &str, now: Timestamp) { - let day_key = runtime_profile_beijing_day_key(now.to_micros_since_unix_epoch()); - let current = ctx - .db - .profile_daily_free_points() - .user_id() - .find(&user_id.to_string()); - let Some(plan) = resolve_daily_free_refresh_plan( - current.as_ref().map(|row| row.day_key), - current - .as_ref() - .map(|row| row.remaining_points) - .unwrap_or(0), - day_key, - ) else { - return; - }; - - match current { - Some(row) => { - debug_assert!(plan.reset); - ctx.db - .profile_daily_free_points() - .user_id() - .update(ProfileDailyFreePoints { - day_key, - granted_points: plan.granted_points, - remaining_points: plan.granted_points, - updated_at: now, - ..row - }); - update_profile_wallet_balance_for_expiring_points( - ctx, - user_id, - plan.expired_points, - plan.granted_points, - RuntimeProfileWalletLedgerSourceType::DailyFreeReset, - &format!("daily-free-reset:{user_id}:{day_key}"), - now, - daily_free_points_metadata( - "reset", - plan.expired_points, - plan.granted_points, - day_key, - ), - ); - } - None => { - debug_assert!(!plan.reset); - ctx.db - .profile_daily_free_points() - .insert(ProfileDailyFreePoints { - user_id: user_id.to_string(), - day_key, - granted_points: plan.granted_points, - remaining_points: plan.granted_points, - updated_at: now, - }); - update_profile_wallet_balance_for_expiring_points( - ctx, - user_id, - 0, - plan.granted_points, - RuntimeProfileWalletLedgerSourceType::DailyFreeGrant, - &format!("daily-free-grant:{user_id}:{day_key}"), - now, - daily_free_points_metadata("grant", 0, plan.granted_points, day_key), - ); - } - } -} - -fn profile_daily_free_points_resets_at_micros(day_key: i64) -> i64 { - day_key - .saturating_add(1) - .saturating_mul(PROFILE_RUNTIME_DAY_MICROS) - .saturating_sub(PROFILE_TASK_BEIJING_OFFSET_MICROS) -} - -fn build_profile_daily_free_points_snapshot( - ctx: &ReducerContext, - user_id: &str, - now: Timestamp, -) -> RuntimeProfileDailyFreePointsSnapshot { - let day_key = runtime_profile_beijing_day_key(now.to_micros_since_unix_epoch()); - ctx.db - .profile_daily_free_points() - .user_id() - .find(&user_id.to_string()) - .map(|row| RuntimeProfileDailyFreePointsSnapshot { - day_key: row.day_key, - granted_points: row.granted_points, - remaining_points: row.remaining_points, - resets_at_micros: profile_daily_free_points_resets_at_micros(row.day_key), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - }) - .unwrap_or(RuntimeProfileDailyFreePointsSnapshot { - day_key, - granted_points: PROFILE_DAILY_FREE_POINTS_PER_DAY, - remaining_points: PROFILE_DAILY_FREE_POINTS_PER_DAY, - resets_at_micros: profile_daily_free_points_resets_at_micros(day_key), - updated_at_micros: now.to_micros_since_unix_epoch(), - }) -} - -fn refresh_profile_wallet_expiring_points( - ctx: &ReducerContext, - user_id: &str, - membership_now: Timestamp, -) { - refresh_profile_daily_free_points(ctx, user_id, ctx.timestamp); - refresh_profile_membership_cycle(ctx, user_id, membership_now); -} - -fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Timestamp) { - let Some(mut row) = ctx - .db - .profile_membership() - .user_id() - .find(&user_id.to_string()) - else { - return; - }; - - let now_micros = now.to_micros_since_unix_epoch(); - let expires_at_micros = row.expires_at.to_micros_since_unix_epoch(); - if expires_at_micros <= now_micros { - let expired_points = row.cycle_remaining_points; - if row.status != RuntimeProfileMembershipStatus::Normal || expired_points > 0 { - row.status = RuntimeProfileMembershipStatus::Normal; - row.cycle_remaining_points = 0; - row.updated_at = now; - let ledger_id = format!("membership-period-expire:{user_id}:{expires_at_micros}"); - upsert_profile_membership_row(ctx, row); - update_profile_wallet_balance_for_expiring_points( - ctx, - user_id, - expired_points, - 0, - RuntimeProfileWalletLedgerSourceType::MembershipPeriodReset, - &ledger_id, - now, - membership_cycle_metadata("expire", expired_points, 0, None), - ); - } - return; - } - - if row.cycle_resets_at.is_none() { - let Some(initialization) = initialize_missing_profile_membership_cycle(row, now) else { - return; - }; - let ledger_id = format!( - "membership-period-initialize:{user_id}:{}", - initialization.cycle_resets_at.to_micros_since_unix_epoch() - ); - let granted_points_delta = initialization.granted_points_delta; - row = initialization.row; - let cycle_resets_at = row.cycle_resets_at.clone(); - upsert_profile_membership_row(ctx, row); - update_profile_wallet_balance_for_expiring_points( - ctx, - user_id, - 0, - granted_points_delta, - RuntimeProfileWalletLedgerSourceType::MembershipPeriodGrant, - &ledger_id, - now, - membership_cycle_metadata("initialize", 0, granted_points_delta, cycle_resets_at), - ); - let Some(refreshed_row) = ctx - .db - .profile_membership() - .user_id() - .find(&user_id.to_string()) - else { - return; - }; - row = refreshed_row; - } - - let Some(mut next_reset_at) = row.cycle_resets_at else { - return; - }; - if next_reset_at.to_micros_since_unix_epoch() > now_micros { - return; - } - - let period_days = normalized_membership_period_days(row.cycle_period_days); - let period_micros = membership_period_micros(period_days); - let mut next_cycle_started_at = next_reset_at; - let mut guard = 0u32; - while next_reset_at.to_micros_since_unix_epoch() <= now_micros && guard < 120 { - next_cycle_started_at = next_reset_at; - next_reset_at = Timestamp::from_micros_since_unix_epoch( - next_reset_at - .to_micros_since_unix_epoch() - .saturating_add(period_micros), - ); - guard = guard.saturating_add(1); - } - - let expired_points = row.cycle_remaining_points; - let granted_points = - runtime_profile_membership_period_points(row.tier).max(row.cycle_granted_points); - row.cycle_started_at = Some(next_cycle_started_at); - row.cycle_resets_at = Some(next_reset_at); - row.cycle_granted_points = granted_points; - row.cycle_remaining_points = granted_points; - row.cycle_period_days = period_days; - row.updated_at = now; - let reset_micros = next_cycle_started_at.to_micros_since_unix_epoch(); - let ledger_id = format!("membership-period-reset:{user_id}:{reset_micros}"); - upsert_profile_membership_row(ctx, row); - update_profile_wallet_balance_for_expiring_points( - ctx, - user_id, - expired_points, - granted_points, - RuntimeProfileWalletLedgerSourceType::MembershipPeriodReset, - &ledger_id, - now, - membership_cycle_metadata("reset", expired_points, granted_points, Some(next_reset_at)), - ); -} - -fn build_profile_membership_snapshot( - ctx: &ReducerContext, - user_id: &str, -) -> RuntimeProfileMembershipSnapshot { - refresh_profile_wallet_expiring_points(ctx, user_id, ctx.timestamp); - let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); - let membership = ctx - .db - .profile_membership() - .user_id() - .find(&user_id.to_string()); - match membership { - Some(row) if row.expires_at.to_micros_since_unix_epoch() > now_micros => { - RuntimeProfileMembershipSnapshot { - user_id: row.user_id, - status: row.status, - tier: row.tier, - started_at_micros: Some(row.started_at.to_micros_since_unix_epoch()), - expires_at_micros: Some(row.expires_at.to_micros_since_unix_epoch()), - updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()), - cycle_started_at_micros: row - .cycle_started_at - .map(|value| value.to_micros_since_unix_epoch()), - cycle_resets_at_micros: row - .cycle_resets_at - .map(|value| value.to_micros_since_unix_epoch()), - cycle_granted_points: row.cycle_granted_points, - cycle_remaining_points: row.cycle_remaining_points, - cycle_period_days: normalized_membership_period_days(row.cycle_period_days), - } - } - Some(row) => RuntimeProfileMembershipSnapshot { - user_id: row.user_id, - status: RuntimeProfileMembershipStatus::Normal, - tier: RuntimeProfileMembershipTier::Normal, - started_at_micros: Some(row.started_at.to_micros_since_unix_epoch()), - expires_at_micros: Some(row.expires_at.to_micros_since_unix_epoch()), - updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()), - cycle_started_at_micros: row - .cycle_started_at - .map(|value| value.to_micros_since_unix_epoch()), - cycle_resets_at_micros: row - .cycle_resets_at - .map(|value| value.to_micros_since_unix_epoch()), - cycle_granted_points: row.cycle_granted_points, - cycle_remaining_points: 0, - cycle_period_days: normalized_membership_period_days(row.cycle_period_days), - }, - None => RuntimeProfileMembershipSnapshot { - user_id: user_id.to_string(), - status: RuntimeProfileMembershipStatus::Normal, - tier: RuntimeProfileMembershipTier::Normal, - started_at_micros: None, - expires_at_micros: None, - updated_at_micros: None, - cycle_started_at_micros: None, - cycle_resets_at_micros: None, - cycle_granted_points: 0, - cycle_remaining_points: 0, - cycle_period_days: PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS, - }, - } -} - -fn apply_profile_membership_purchase( - ctx: &ReducerContext, - user_id: &str, - product: &RuntimeProfileRechargeProductSnapshot, - purchased_at: Timestamp, -) -> Result { - refresh_profile_wallet_expiring_points(ctx, user_id, purchased_at); - let tier = product.tier; - let duration_days = product.duration_days; - let period_days = membership_product_period_days(product); - let period_points = membership_product_period_points(product); - let current = ctx - .db - .profile_membership() - .user_id() - .find(&user_id.to_string()); - let purchased_at_micros = purchased_at.to_micros_since_unix_epoch(); - let duration_micros = i64::from(duration_days).saturating_mul(PROFILE_RUNTIME_DAY_MICROS); - - let (next_row, period_points_delta) = match current { - Some(row) if active_membership_row_at(&row, purchased_at) => { - let purchase_mode = resolve_active_membership_purchase_mode(row.tier, tier)?; - if purchase_mode == ActiveMembershipPurchaseMode::Renew { - ( - apply_active_membership_renew_row(row, tier, duration_days, purchased_at), - 0, - ) - } else { - apply_active_membership_upgrade_row( - row, - tier, - period_days, - period_points, - purchased_at, - ) - } - } - Some(row) => { - let expires_at = Timestamp::from_micros_since_unix_epoch( - purchased_at_micros.saturating_add(duration_micros), - ); - ( - ProfileMembership { - user_id: row.user_id, - status: RuntimeProfileMembershipStatus::Active, - tier, - started_at: purchased_at, - expires_at, - updated_at: purchased_at, - cycle_started_at: Some(purchased_at), - cycle_resets_at: Some(membership_cycle_reset_at(purchased_at, period_days)), - cycle_granted_points: period_points, - cycle_remaining_points: period_points, - cycle_period_days: period_days, - }, - period_points, - ) - } - None => { - let expires_at = Timestamp::from_micros_since_unix_epoch( - purchased_at_micros.saturating_add(duration_micros), - ); - ( - ProfileMembership { - user_id: user_id.to_string(), - status: RuntimeProfileMembershipStatus::Active, - tier, - started_at: purchased_at, - expires_at, - updated_at: purchased_at, - cycle_started_at: Some(purchased_at), - cycle_resets_at: Some(membership_cycle_reset_at(purchased_at, period_days)), - cycle_granted_points: period_points, - cycle_remaining_points: period_points, - cycle_period_days: period_days, - }, - period_points, - ) - } - }; - - let expires_at = next_row.expires_at; - let cycle_resets_at = next_row.cycle_resets_at.clone(); - upsert_profile_membership_row(ctx, next_row); - - if period_points_delta > 0 { - let ledger_id = format!( - "membership-period-grant:{user_id}:{}:{}", - purchased_at_micros, product.product_id - ); - update_profile_wallet_balance_for_expiring_points( - ctx, - user_id, - 0, - period_points_delta, - RuntimeProfileWalletLedgerSourceType::MembershipPeriodGrant, - &ledger_id, - purchased_at, - membership_cycle_metadata("purchase", 0, period_points_delta, cycle_resets_at), - ); - } - - Ok(ProfileMembershipPurchaseResult { - expires_at, - period_points_delta, - }) -} - -fn apply_profile_wallet_delta( - ctx: &ReducerContext, - user_id: &str, - amount_delta: u64, - source_type: RuntimeProfileWalletLedgerSourceType, - ledger_id: &str, - created_at: Timestamp, -) -> Result { - let amount_delta = convert_runtime_profile_wallet_unsigned_delta(amount_delta) - .map_err(|error| error.to_string())?; - apply_profile_wallet_signed_delta( - ctx, - user_id, - amount_delta, - source_type, - ledger_id, - created_at, - false, - PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON, - ) -} - -pub(crate) fn grant_profile_wallet_points( - ctx: &ReducerContext, - user_id: &str, - amount_delta: u64, - source_type: RuntimeProfileWalletLedgerSourceType, - ledger_id: &str, - created_at: Timestamp, -) -> Result { - apply_profile_wallet_delta( - ctx, - user_id, - amount_delta, - source_type, - ledger_id, - created_at, - ) -} - -pub(crate) fn grant_profile_wallet_points_with_metadata( - ctx: &ReducerContext, - user_id: &str, - amount_delta: u64, - source_type: RuntimeProfileWalletLedgerSourceType, - ledger_id: &str, - created_at: Timestamp, - metadata_json: &str, -) -> Result { - apply_profile_wallet_signed_delta( - ctx, - user_id, - convert_runtime_profile_wallet_unsigned_delta(amount_delta) - .map_err(|error| error.to_string())?, - source_type, - ledger_id, - created_at, - true, - metadata_json, - ) -} - -fn apply_profile_wallet_adjustment( - ctx: &ReducerContext, - input: RuntimeProfileWalletAdjustmentInput, - source_type: RuntimeProfileWalletLedgerSourceType, - consume: bool, -) -> Result { - let validated_input = build_runtime_profile_wallet_adjustment_input_with_metadata( - input.user_id, - input.amount, - input.ledger_id, - input.created_at_micros, - input.metadata_json, - ) - .map_err(|error| error.to_string())?; - let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros); - let unsigned_delta = convert_runtime_profile_wallet_unsigned_delta(validated_input.amount) - .map_err(|error| error.to_string())?; - if consume { - if !validated_input - .ledger_id - .starts_with(ASSET_OPERATION_CONSUME_LEDGER_PREFIX) - { - return Err("资产操作扣费流水缺少合法前缀".to_string()); - } - require_asset_operation_consume_unsettled( - ctx, - &validated_input.user_id, - unsigned_delta, - &validated_input.ledger_id, - )?; - } else { - if !validated_input - .ledger_id - .starts_with(ASSET_OPERATION_REFUND_LEDGER_PREFIX) - { - return Err("资产操作退款流水缺少合法前缀".to_string()); - } - match resolve_asset_operation_refund_disposition_from_ledger( - ctx, - &validated_input.user_id, - unsigned_delta, - &validated_input.ledger_id, - )? { - AssetOperationRefundDisposition::Apply => {} - AssetOperationRefundDisposition::RecordIntent => { - record_asset_operation_wallet_settlement( - ctx, - &validated_input.user_id, - unsigned_delta, - &validated_input.ledger_id, - created_at, - )?; - return get_profile_dashboard_snapshot( - ctx, - RuntimeProfileDashboardGetInput { - user_id: validated_input.user_id, - }, - ); - } - AssetOperationRefundDisposition::Noop => { - record_asset_operation_wallet_settlement( - ctx, - &validated_input.user_id, - unsigned_delta, - &validated_input.ledger_id, - created_at, - )?; - return get_profile_dashboard_snapshot( - ctx, - RuntimeProfileDashboardGetInput { - user_id: validated_input.user_id, - }, - ); - } - } - } - let amount_delta = if consume { - -unsigned_delta - } else { - unsigned_delta - }; - - apply_profile_wallet_signed_delta( - ctx, - &validated_input.user_id, - amount_delta, - source_type, - &validated_input.ledger_id, - created_at, - true, - &validated_input.metadata_json, - )?; - if !consume { - record_asset_operation_wallet_settlement( - ctx, - &validated_input.user_id, - unsigned_delta, - &validated_input.ledger_id, - created_at, - )?; - } - get_profile_dashboard_snapshot( - ctx, - RuntimeProfileDashboardGetInput { - user_id: validated_input.user_id, - }, - ) -} - -pub(crate) fn settle_external_generation_attempt_refund( - ctx: &ReducerContext, - job_id: &str, - attempt: u32, - user_id: &str, - amount: u64, - settled_at: Timestamp, - settlement_reason: &str, -) -> Result, String> { - if amount == 0 { - return Ok(None); - } - let refund_ledger_id = format!( - "{ASSET_OPERATION_REFUND_LEDGER_PREFIX}external_generation_job:{}:attempt:{attempt}", - job_id.trim() - ); - apply_profile_wallet_adjustment( - ctx, - RuntimeProfileWalletAdjustmentInput { - user_id: user_id.to_string(), - amount, - ledger_id: refund_ledger_id.clone(), - created_at_micros: settled_at.to_micros_since_unix_epoch(), - metadata_json: serde_json::json!({ - "externalGenerationJobId": job_id.trim(), - "claimAttempt": attempt, - "settlementReason": settlement_reason, - }) - .to_string(), - }, - RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, - false, - )?; - Ok(Some(refund_ledger_id)) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum AssetOperationRefundDisposition { - Apply, - RecordIntent, - Noop, -} - -fn resolve_asset_operation_refund_disposition_from_ledger( - ctx: &ReducerContext, - user_id: &str, - amount: i64, - refund_ledger_id: &str, -) -> Result { - let consume_ledger_id = asset_operation_consume_ledger_id(refund_ledger_id)?; - let existing_refund = ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&refund_ledger_id.to_string()); - let consume_ledger = ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&consume_ledger_id); - let settlement = ctx - .db - .asset_operation_wallet_settlement() - .consume_ledger_id() - .find(&consume_ledger_id); - resolve_asset_operation_refund_disposition( - user_id, - amount, - refund_ledger_id, - settlement.as_ref(), - existing_refund.as_ref(), - consume_ledger.as_ref(), - ) -} - -fn resolve_asset_operation_refund_disposition( - user_id: &str, - amount: i64, - refund_ledger_id: &str, - settlement: Option<&AssetOperationWalletSettlement>, - existing_refund: Option<&ProfileWalletLedger>, - consume_ledger: Option<&ProfileWalletLedger>, -) -> Result { - if amount <= 0 { - return Err("资产操作退款金额必须大于 0".to_string()); - } - let consume_ledger_id = asset_operation_consume_ledger_id(refund_ledger_id)?; - - if let Some(settlement) = settlement { - validate_asset_operation_wallet_settlement( - settlement, - &consume_ledger_id, - refund_ledger_id, - user_id, - amount, - )?; - if let Some(existing_refund) = existing_refund { - validate_asset_operation_wallet_ledger( - existing_refund, - refund_ledger_id, - user_id, - amount, - RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, - "退款", - )?; - } - return Ok(AssetOperationRefundDisposition::Noop); - } - - if let Some(existing_refund) = existing_refund { - validate_asset_operation_wallet_ledger( - existing_refund, - refund_ledger_id, - user_id, - amount, - RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, - "退款", - )?; - return Ok(AssetOperationRefundDisposition::Noop); - } - - let Some(consume_ledger) = consume_ledger else { - return Ok(AssetOperationRefundDisposition::RecordIntent); - }; - validate_asset_operation_wallet_ledger( - consume_ledger, - &consume_ledger_id, - user_id, - -amount, - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, - "扣费", - )?; - Ok(AssetOperationRefundDisposition::Apply) -} - -fn require_asset_operation_consume_unsettled( - ctx: &ReducerContext, - user_id: &str, - amount: i64, - consume_ledger_id: &str, -) -> Result<(), String> { - if amount <= 0 { - return Err("资产操作扣费金额必须大于 0".to_string()); - } - let refund_ledger_id = asset_operation_refund_ledger_id(consume_ledger_id)?; - let Some(settlement) = ctx - .db - .asset_operation_wallet_settlement() - .consume_ledger_id() - .find(&consume_ledger_id.to_string()) - else { - return Ok(()); - }; - validate_asset_operation_wallet_settlement( - &settlement, - consume_ledger_id, - &refund_ledger_id, - user_id, - amount, - )?; - Err("资产操作扣费已结算,拒绝迟到或重复执行".to_string()) -} - -fn record_asset_operation_wallet_settlement( - ctx: &ReducerContext, - user_id: &str, - amount: i64, - refund_ledger_id: &str, - settled_at: Timestamp, -) -> Result<(), String> { - if amount <= 0 { - return Err("资产操作退款金额必须大于 0".to_string()); - } - let amount = u64::try_from(amount).map_err(|_| "资产操作退款金额超出范围".to_string())?; - let consume_ledger_id = asset_operation_consume_ledger_id(refund_ledger_id)?; - if let Some(existing) = ctx - .db - .asset_operation_wallet_settlement() - .consume_ledger_id() - .find(&consume_ledger_id) - { - return validate_asset_operation_wallet_settlement( - &existing, - &consume_ledger_id, - refund_ledger_id, - user_id, - i64::try_from(amount).map_err(|_| "资产操作退款金额超出范围".to_string())?, - ); - } - ctx.db - .asset_operation_wallet_settlement() - .insert(AssetOperationWalletSettlement { - consume_ledger_id, - refund_ledger_id: refund_ledger_id.to_string(), - user_id: user_id.to_string(), - amount, - settled_at, - }); - Ok(()) -} - -fn validate_asset_operation_wallet_settlement( - settlement: &AssetOperationWalletSettlement, - expected_consume_ledger_id: &str, - expected_refund_ledger_id: &str, - expected_user_id: &str, - expected_amount: i64, -) -> Result<(), String> { - let expected_amount = - u64::try_from(expected_amount).map_err(|_| "资产操作结算金额必须大于 0".to_string())?; - if expected_amount == 0 { - return Err("资产操作结算金额必须大于 0".to_string()); - } - if settlement.consume_ledger_id != expected_consume_ledger_id { - return Err("资产操作结算扣费流水 ID 不匹配".to_string()); - } - if settlement.refund_ledger_id != expected_refund_ledger_id { - return Err("资产操作结算退款流水 ID 不匹配".to_string()); - } - if settlement.user_id != expected_user_id { - return Err("资产操作结算用户不匹配".to_string()); - } - if settlement.amount != expected_amount { - return Err("资产操作结算金额不匹配".to_string()); - } - Ok(()) -} - -fn asset_operation_consume_ledger_id(refund_ledger_id: &str) -> Result { - refund_ledger_id - .strip_prefix(ASSET_OPERATION_REFUND_LEDGER_PREFIX) - .map(|suffix| format!("{ASSET_OPERATION_CONSUME_LEDGER_PREFIX}{suffix}")) - .ok_or_else(|| format!("资产操作退款流水 {refund_ledger_id} 缺少合法前缀")) -} - -fn asset_operation_refund_ledger_id(consume_ledger_id: &str) -> Result { - consume_ledger_id - .strip_prefix(ASSET_OPERATION_CONSUME_LEDGER_PREFIX) - .map(|suffix| format!("{ASSET_OPERATION_REFUND_LEDGER_PREFIX}{suffix}")) - .ok_or_else(|| format!("资产操作扣费流水 {consume_ledger_id} 缺少合法前缀")) -} - -fn validate_asset_operation_wallet_ledger( - ledger: &ProfileWalletLedger, - expected_ledger_id: &str, - expected_user_id: &str, - expected_amount_delta: i64, - expected_source_type: RuntimeProfileWalletLedgerSourceType, - label: &str, -) -> Result<(), String> { - if ledger.wallet_ledger_id != expected_ledger_id { - return Err(format!("资产操作{label}流水 ID 不匹配")); - } - if ledger.user_id != expected_user_id { - return Err(format!("资产操作{label}流水用户不匹配")); - } - if ledger.amount_delta != expected_amount_delta { - return Err(format!("资产操作{label}流水金额不匹配")); - } - if ledger.source_type != expected_source_type { - return Err(format!("资产操作{label}流水来源不匹配")); - } - Ok(()) -} - -fn consume_profile_membership_cycle_points( - ctx: &ReducerContext, - user_id: &str, - amount: u64, - consumed_at: Timestamp, -) -> MembershipCyclePointMutation { - if amount == 0 { - return MembershipCyclePointMutation::none(); - } - let Some(mut row) = ctx - .db - .profile_membership() - .user_id() - .find(&user_id.to_string()) - else { - return MembershipCyclePointMutation::none(); - }; - if !active_membership_row_at(&row, consumed_at) || row.cycle_remaining_points == 0 { - return MembershipCyclePointMutation::none(); - } - - let consumed = row.cycle_remaining_points.min(amount); - let cycle_resets_at_micros = row - .cycle_resets_at - .map(|value| value.to_micros_since_unix_epoch()); - row.cycle_remaining_points -= consumed; - row.updated_at = consumed_at; - upsert_profile_membership_row(ctx, row); - MembershipCyclePointMutation { - points: consumed, - cycle_resets_at_micros, - } -} - -fn consume_profile_daily_free_points( - ctx: &ReducerContext, - user_id: &str, - amount: u64, - consumed_at: Timestamp, -) -> DailyFreePointMutation { - if amount == 0 { - return DailyFreePointMutation::none(); - } - let day_key = runtime_profile_beijing_day_key(consumed_at.to_micros_since_unix_epoch()); - let Some(mut row) = ctx - .db - .profile_daily_free_points() - .user_id() - .find(&user_id.to_string()) - else { - return DailyFreePointMutation::none(); - }; - if row.day_key != day_key || row.remaining_points == 0 { - return DailyFreePointMutation::none(); - } - - let consumed = row.remaining_points.min(amount); - row.remaining_points -= consumed; - row.updated_at = consumed_at; - ctx.db.profile_daily_free_points().user_id().update(row); - DailyFreePointMutation { - points: consumed, - day_key: Some(day_key), - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct DailyFreeRefundRestorePlan { - restored_points: u64, - target_day_key: i64, - granted_points_delta: u64, -} - -fn resolve_daily_free_refund_restore_plan( - consumed_day_key: i64, - current_day_key: i64, - row_day_key: i64, - granted_points: u64, - remaining_points: u64, - candidate_points: u64, - refund_amount: u64, -) -> Option { - if candidate_points == 0 || refund_amount == 0 || row_day_key != current_day_key { - return None; - } - - let candidate_points = candidate_points.min(refund_amount); - if consumed_day_key == current_day_key { - let restored_points = candidate_points.min(granted_points.saturating_sub(remaining_points)); - return (restored_points > 0).then_some(DailyFreeRefundRestorePlan { - restored_points, - target_day_key: current_day_key, - granted_points_delta: 0, - }); - } - if consumed_day_key > current_day_key { - return None; - } - - Some(DailyFreeRefundRestorePlan { - restored_points: candidate_points, - target_day_key: current_day_key, - granted_points_delta: candidate_points, - }) -} - -fn restore_profile_daily_free_points_for_refund( - ctx: &ReducerContext, - user_id: &str, - amount: u64, - refund_ledger_id: &str, - refunded_at: Timestamp, -) -> DailyFreePointMutation { - if amount == 0 { - return DailyFreePointMutation::none(); - } - let Some(consume_ledger_id) = refund_ledger_id - .strip_prefix(ASSET_OPERATION_REFUND_LEDGER_PREFIX) - .map(|suffix| format!("{ASSET_OPERATION_CONSUME_LEDGER_PREFIX}{suffix}")) - else { - return DailyFreePointMutation::none(); - }; - let Some(consume_ledger) = ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&consume_ledger_id) - else { - return DailyFreePointMutation::none(); - }; - let Some(metadata_json) = consume_ledger.metadata_json.as_deref() else { - return DailyFreePointMutation::none(); - }; - let restore_candidate = - daily_free_refund_restore_candidate_from_consume_metadata(metadata_json); - let Some(expected_day_key) = restore_candidate.day_key else { - return DailyFreePointMutation::none(); - }; - let current_day_key = runtime_profile_beijing_day_key(refunded_at.to_micros_since_unix_epoch()); - - let Some(mut row) = ctx - .db - .profile_daily_free_points() - .user_id() - .find(&user_id.to_string()) - else { - return DailyFreePointMutation::none(); - }; - let Some(plan) = resolve_daily_free_refund_restore_plan( - expected_day_key, - current_day_key, - row.day_key, - row.granted_points, - row.remaining_points, - restore_candidate.points, - amount, - ) else { - return DailyFreePointMutation::none(); - }; - - row.granted_points = row.granted_points.saturating_add(plan.granted_points_delta); - row.remaining_points = row.remaining_points.saturating_add(plan.restored_points); - row.updated_at = refunded_at; - ctx.db.profile_daily_free_points().user_id().update(row); - DailyFreePointMutation { - points: plan.restored_points, - day_key: Some(plan.target_day_key), - } -} - -fn restore_profile_membership_cycle_points_for_refund( - ctx: &ReducerContext, - user_id: &str, - amount: u64, - refund_ledger_id: &str, - refunded_at: Timestamp, -) -> MembershipCyclePointMutation { - if amount == 0 { - return MembershipCyclePointMutation::none(); - } - let Some(consume_ledger_id) = refund_ledger_id - .strip_prefix(ASSET_OPERATION_REFUND_LEDGER_PREFIX) - .map(|suffix| format!("{ASSET_OPERATION_CONSUME_LEDGER_PREFIX}{suffix}")) - else { - return MembershipCyclePointMutation::none(); - }; - let Some(consume_ledger) = ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&consume_ledger_id) - else { - return MembershipCyclePointMutation::none(); - }; - let Some(metadata_json) = consume_ledger.metadata_json.as_deref() else { - return MembershipCyclePointMutation::none(); - }; - let restore_candidate = - membership_refund_restore_candidate_from_consume_metadata(metadata_json); - if restore_candidate.points == 0 { - return MembershipCyclePointMutation::none(); - } - let Some(expected_cycle_resets_at_micros) = restore_candidate.cycle_resets_at_micros else { - return MembershipCyclePointMutation::none(); - }; - - let Some(mut row) = ctx - .db - .profile_membership() - .user_id() - .find(&user_id.to_string()) - else { - return MembershipCyclePointMutation::none(); - }; - if !active_membership_row_at(&row, refunded_at) { - return MembershipCyclePointMutation::none(); - } - if row - .cycle_resets_at - .map(|value| value.to_micros_since_unix_epoch()) - != Some(expected_cycle_resets_at_micros) - { - return MembershipCyclePointMutation::none(); - } - - let available_restore_room = row - .cycle_granted_points - .saturating_sub(row.cycle_remaining_points); - let restored = restore_candidate - .points - .min(amount) - .min(available_restore_room); - if restored == 0 { - return MembershipCyclePointMutation::none(); - } - - row.cycle_remaining_points = row.cycle_remaining_points.saturating_add(restored); - row.updated_at = refunded_at; - upsert_profile_membership_row(ctx, row); - MembershipCyclePointMutation { - points: restored, - cycle_resets_at_micros: Some(expected_cycle_resets_at_micros), - } -} - -fn membership_refund_restore_candidate_from_consume_metadata( - metadata_json: &str, -) -> MembershipCyclePointMutation { - let Some(metadata) = serde_json::from_str::(metadata_json) - .ok() - .filter(JsonValue::is_object) - else { - return MembershipCyclePointMutation::none(); - }; - let membership_period_delta = metadata - .get("membershipPeriodPointsDelta") - .and_then(JsonValue::as_i64) - .unwrap_or(0); - if membership_period_delta >= 0 { - return MembershipCyclePointMutation::none(); - } - MembershipCyclePointMutation { - points: membership_period_delta.unsigned_abs(), - cycle_resets_at_micros: metadata - .get("cycleResetsAtMicros") - .and_then(JsonValue::as_i64), - } -} - -fn daily_free_refund_restore_candidate_from_consume_metadata( - metadata_json: &str, -) -> DailyFreePointMutation { - let Some(metadata) = serde_json::from_str::(metadata_json) - .ok() - .filter(JsonValue::is_object) - else { - return DailyFreePointMutation::none(); - }; - let daily_free_delta = metadata - .get("dailyFreePointsDelta") - .and_then(JsonValue::as_i64) - .unwrap_or(0); - if daily_free_delta >= 0 { - return DailyFreePointMutation::none(); - } - DailyFreePointMutation { - points: daily_free_delta.unsigned_abs(), - day_key: metadata.get("dailyFreeDayKey").and_then(JsonValue::as_i64), - } -} - -fn metadata_with_profile_wallet_delta_split( - metadata_json: &str, - daily_free_delta: i64, - membership_period_delta: i64, - permanent_delta: i64, - daily_free_day_key: Option, - cycle_resets_at_micros: Option, -) -> String { - let mut metadata = serde_json::from_str::(metadata_json) - .ok() - .filter(JsonValue::is_object) - .unwrap_or_else(|| json!({})); - if let Some(object) = metadata.as_object_mut() { - object.insert("dailyFreePointsDelta".to_string(), json!(daily_free_delta)); - object.insert( - "membershipPeriodPointsDelta".to_string(), - json!(membership_period_delta), - ); - object.insert("permanentPointsDelta".to_string(), json!(permanent_delta)); - if let Some(daily_free_day_key) = daily_free_day_key { - object.insert("dailyFreeDayKey".to_string(), json!(daily_free_day_key)); - } - if let Some(cycle_resets_at_micros) = cycle_resets_at_micros { - object.insert( - "cycleResetsAtMicros".to_string(), - json!(cycle_resets_at_micros), - ); - } - } - serde_json::to_string(&metadata).unwrap_or_else(|_| metadata_json.to_string()) -} - -fn metadata_with_profile_wallet_consumption_split( - metadata_json: &str, - total_consumed: u64, - daily_free_consumed: DailyFreePointMutation, - membership_period_consumed: MembershipCyclePointMutation, -) -> String { - if total_consumed == 0 { - return metadata_json.to_string(); - } - let permanent_consumed = total_consumed - .saturating_sub(daily_free_consumed.points) - .saturating_sub(membership_period_consumed.points); - metadata_with_profile_wallet_delta_split( - metadata_json, - -(daily_free_consumed.points as i64), - -(membership_period_consumed.points as i64), - -(permanent_consumed as i64), - daily_free_consumed.day_key, - membership_period_consumed.cycle_resets_at_micros, - ) -} - -fn metadata_with_profile_wallet_refund_split( - metadata_json: &str, - total_refunded: u64, - daily_free_refunded: DailyFreePointMutation, - membership_period_refunded: MembershipCyclePointMutation, -) -> String { - if total_refunded == 0 { - return metadata_json.to_string(); - } - let permanent_refunded = total_refunded - .saturating_sub(daily_free_refunded.points) - .saturating_sub(membership_period_refunded.points); - metadata_with_profile_wallet_delta_split( - metadata_json, - daily_free_refunded.points as i64, - membership_period_refunded.points as i64, - permanent_refunded as i64, - daily_free_refunded.day_key, - membership_period_refunded.cycle_resets_at_micros, - ) -} - -fn apply_profile_wallet_signed_delta( - ctx: &ReducerContext, - user_id: &str, - amount_delta: i64, - source_type: RuntimeProfileWalletLedgerSourceType, - ledger_id: &str, - created_at: Timestamp, - idempotent: bool, - metadata_json: &str, -) -> Result { - let settled_at = ctx.timestamp; - let ledger_recorded_at = profile_wallet_ledger_recorded_at(created_at, settled_at); - refresh_profile_wallet_expiring_points(ctx, user_id, settled_at); - if idempotent { - if let Some(existing) = ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&ledger_id.to_string()) - { - validate_idempotent_profile_wallet_ledger( - &existing, - user_id, - amount_delta, - source_type, - )?; - return Ok(profile_wallet_balance(ctx, user_id)); - } - } - if amount_delta < 0 { - validate_runtime_profile_wallet_debit_restrictions( - amount_delta, - source_type, - has_profile_wallet_manual_restriction(ctx, user_id), - has_profile_recharge_refund_wallet_freeze(ctx, user_id), - )?; - } - - let current = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&user_id.to_string()); - let previous_balance = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0); - if amount_delta < 0 - && source_type != RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery - { - validate_runtime_profile_wallet_debit_availability( - previous_balance, - active_profile_recharge_refund_hold_points(ctx, user_id), - amount_delta.unsigned_abs(), - )?; - } - let next_balance = calculate_runtime_profile_wallet_balance(previous_balance, amount_delta) - .map_err(|error| error.to_string())?; - let created_state_at = current - .as_ref() - .map(|row| row.created_at) - .unwrap_or(ledger_recorded_at); - let daily_free_consumed = if amount_delta < 0 - && !matches!( - source_type, - RuntimeProfileWalletLedgerSourceType::MembershipPeriodReset - | RuntimeProfileWalletLedgerSourceType::DailyFreeReset - ) { - consume_profile_daily_free_points(ctx, user_id, amount_delta.unsigned_abs(), settled_at) - } else { - DailyFreePointMutation::none() - }; - let membership_period_consumed = if amount_delta < 0 - && !matches!( - source_type, - RuntimeProfileWalletLedgerSourceType::MembershipPeriodReset - | RuntimeProfileWalletLedgerSourceType::DailyFreeReset - ) { - consume_profile_membership_cycle_points( - ctx, - user_id, - amount_delta - .unsigned_abs() - .saturating_sub(daily_free_consumed.points), - settled_at, - ) - } else { - MembershipCyclePointMutation::none() - }; - let daily_free_refunded = if amount_delta > 0 - && source_type == RuntimeProfileWalletLedgerSourceType::AssetOperationRefund - { - restore_profile_daily_free_points_for_refund( - ctx, - user_id, - amount_delta as u64, - ledger_id, - settled_at, - ) - } else { - DailyFreePointMutation::none() - }; - let membership_period_refunded = if amount_delta > 0 - && source_type == RuntimeProfileWalletLedgerSourceType::AssetOperationRefund - { - restore_profile_membership_cycle_points_for_refund( - ctx, - user_id, - (amount_delta as u64).saturating_sub(daily_free_refunded.points), - ledger_id, - settled_at, - ) - } else { - MembershipCyclePointMutation::none() - }; - let ledger_metadata_json = if amount_delta < 0 { - metadata_with_profile_wallet_consumption_split( - metadata_json, - amount_delta.unsigned_abs(), - daily_free_consumed, - membership_period_consumed, - ) - } else if amount_delta > 0 { - metadata_with_profile_wallet_refund_split( - metadata_json, - amount_delta as u64, - daily_free_refunded, - membership_period_refunded, - ) - } else { - metadata_json.to_string() - }; - - if let Some(existing) = current { - ctx.db - .profile_dashboard_state() - .user_id() - .delete(&existing.user_id); - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: user_id.to_string(), - wallet_balance: next_balance, - total_play_time_ms: existing.total_play_time_ms, - created_at: existing.created_at, - updated_at: ledger_recorded_at, - }); - } else { - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: user_id.to_string(), - wallet_balance: next_balance, - total_play_time_ms: 0, - created_at: created_state_at, - updated_at: ledger_recorded_at, - }); - } - - ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger { - wallet_ledger_id: ledger_id.to_string(), - user_id: user_id.to_string(), - amount_delta, - balance_after: next_balance, - source_type, - created_at: ledger_recorded_at, - metadata_json: Some(ledger_metadata_json), - }); - - if amount_delta > 0 { - repay_profile_recharge_refund_debt_from_permanent_points(ctx, user_id); - } - Ok(profile_wallet_balance(ctx, user_id)) -} - -fn profile_wallet_ledger_recorded_at( - _business_event_at: Timestamp, - settled_at: Timestamp, -) -> Timestamp { - settled_at -} - -fn validate_idempotent_profile_wallet_ledger( - existing: &ProfileWalletLedger, - expected_user_id: &str, - expected_amount_delta: i64, - expected_source_type: RuntimeProfileWalletLedgerSourceType, -) -> Result<(), String> { - if existing.user_id != expected_user_id { - return Err("钱包幂等流水用户不匹配".to_string()); - } - if existing.amount_delta != expected_amount_delta { - return Err("钱包幂等流水金额不匹配".to_string()); - } - if existing.source_type != expected_source_type { - return Err("钱包幂等流水来源不匹配".to_string()); - } - Ok(()) -} - -fn has_profile_points_recharged(ctx: &ReducerContext, user_id: &str) -> bool { - ctx.db - .profile_recharge_order() - .by_profile_recharge_order_user_id() - .filter(user_id) - .any(|row| { - row.user_id == user_id - && row.kind == RuntimeProfileRechargeProductKind::Points - && profile_recharge_order_counts_as_paid_purchase(&row) - }) -} - -fn has_profile_product_recharged(ctx: &ReducerContext, user_id: &str, product_id: &str) -> bool { - ctx.db - .profile_recharge_order() - .by_profile_recharge_order_user_id() - .filter(user_id) - .any(|row| { - row.user_id == user_id - && row.product_id == product_id - && row.kind == RuntimeProfileRechargeProductKind::Points - && profile_recharge_order_counts_as_paid_purchase(&row) - }) -} - -fn profile_recharge_order_counts_as_paid_purchase(order: &ProfileRechargeOrder) -> bool { - order.paid_at.is_some() -} - -fn has_profile_recharge_refund_wallet_freeze(ctx: &ReducerContext, user_id: &str) -> bool { - ctx.db - .profile_recharge_order_refund_settlement() - .by_profile_recharge_order_refund_settlement_user_id() - .filter(user_id) - .any(|row| profile_recharge_refund_settlement_freezes_wallet(&row)) -} - -fn profile_recharge_refund_settlement_freezes_wallet( - settlement: &ProfileRechargeOrderRefundSettlement, -) -> bool { - settlement.wallet_frozen || settlement.unrecovered_points > 0 -} - -fn has_profile_business_wallet_ledger(ctx: &ReducerContext, user_id: &str) -> bool { - ctx.db - .profile_wallet_ledger() - .by_profile_wallet_ledger_user_id() - .filter(user_id) - .any(|row| { - row.user_id == user_id - && profile_wallet_ledger_source_blocks_legacy_snapshot_sync(row.source_type) - }) -} - -fn profile_wallet_ledger_source_blocks_legacy_snapshot_sync( - source_type: RuntimeProfileWalletLedgerSourceType, -) -> bool { - !matches!( - source_type, - RuntimeProfileWalletLedgerSourceType::SnapshotSync - | RuntimeProfileWalletLedgerSourceType::DailyFreeGrant - | RuntimeProfileWalletLedgerSourceType::DailyFreeReset - ) -} - -fn merge_legacy_wallet_balance_with_daily_free_points( - legacy_balance: u64, - daily_free_remaining_points: u64, -) -> u64 { - legacy_balance.saturating_add(daily_free_remaining_points) -} - -fn latest_profile_recharge_order( - ctx: &ReducerContext, - user_id: &str, -) -> Option { - let mut orders = ctx - .db - .profile_recharge_order() - .by_profile_recharge_order_user_id() - .filter(user_id) - .collect::>(); - orders.sort_by(|left, right| { - right - .created_at - .to_micros_since_unix_epoch() - .cmp(&left.created_at.to_micros_since_unix_epoch()) - .then_with(|| left.order_id.cmp(&right.order_id)) - }); - orders.into_iter().next() -} - -fn count_profile_redeem_code_user_usage(ctx: &ReducerContext, code: &str, user_id: &str) -> u32 { - ctx.db - .profile_redeem_code_usage() - .by_profile_redeem_code_usage_code_user_id() - .filter((code, user_id)) - .count() as u32 -} - -fn resolve_profile_redeem_code_allowed_user_ids( - ctx: &ReducerContext, - input: &RuntimeProfileRedeemCodeAdminUpsertInput, -) -> Result, String> { - if input.mode != RuntimeProfileRedeemCodeMode::Private { - return Ok(Vec::new()); - } - - let mut allowed_user_ids = input.allowed_user_ids.clone(); - for public_user_code in &input.allowed_public_user_codes { - if let Some(account) = ctx - .db - .user_account() - .by_user_account_public_code() - .filter(public_user_code) - .next() - { - allowed_user_ids.push(account.user_id); - } - } - allowed_user_ids.sort(); - allowed_user_ids.dedup(); - - if allowed_user_ids.is_empty() { - return Err("私有兑换码必须指定可兑换用户".to_string()); - } - - Ok(allowed_user_ids) -} - -fn build_profile_redeem_code_snapshot_from_row( - row: &ProfileRedeemCode, -) -> RuntimeProfileRedeemCodeSnapshot { - RuntimeProfileRedeemCodeSnapshot { - code: row.code.clone(), - mode: row.mode, - reward_points: row.reward_points, - max_uses: row.max_uses, - global_used_count: row.global_used_count, - enabled: row.enabled, - allowed_user_ids: row.allowed_user_ids.clone(), - created_by: row.created_by.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - starts_at_micros: row - .starts_at - .map(|value| value.to_micros_since_unix_epoch()), - expires_at_micros: row - .expires_at - .map(|value| value.to_micros_since_unix_epoch()), - } -} - -fn build_profile_invite_code_snapshot_from_row( - row: &ProfileInviteCode, -) -> RuntimeProfileInviteCodeSnapshot { - RuntimeProfileInviteCodeSnapshot { - user_id: row.user_id.clone(), - invite_code: row.invite_code.clone(), - metadata_json: row.metadata_json.clone(), - starts_at_micros: row - .starts_at - .map(|value| value.to_micros_since_unix_epoch()), - expires_at_micros: row - .expires_at - .map(|value| value.to_micros_since_unix_epoch()), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_profile_code_operation_snapshot_from_row( - row: &ProfileCodeOperation, -) -> RuntimeProfileCodeOperationSnapshot { - RuntimeProfileCodeOperationSnapshot { - operation_id: row.operation_id.clone(), - code_kind: row.code_kind.clone(), - code: row.code.clone(), - action: row.action.clone(), - operator_user_id: row.operator_user_id.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - } -} - -fn build_profile_wallet_ledger_snapshot_from_row( - row: &ProfileWalletLedger, -) -> RuntimeProfileWalletLedgerEntrySnapshot { - RuntimeProfileWalletLedgerEntrySnapshot { - wallet_ledger_id: row.wallet_ledger_id.clone(), - user_id: row.user_id.clone(), - amount_delta: row.amount_delta, - balance_after: row.balance_after, - source_type: row.source_type, - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - metadata_json: row - .metadata_json - .clone() - .unwrap_or_else(|| PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()), - } -} - -fn build_profile_wallet_config_snapshot_from_row( - row: &ProfileWalletConfig, -) -> RuntimeProfileWalletConfigSnapshot { - RuntimeProfileWalletConfigSnapshot { - config_id: row.config_id.clone(), - initial_mud_points: row.initial_mud_points, - created_by: row.created_by.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_by: row.updated_by.clone(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_profile_task_config_snapshot_from_row( - row: &ProfileTaskConfig, -) -> RuntimeProfileTaskConfigSnapshot { - RuntimeProfileTaskConfigSnapshot { - task_id: row.task_id.clone(), - title: row.title.clone(), - description: row.description.clone(), - event_key: row.event_key.clone(), - cycle: row.cycle, - scope_kind: row.scope_kind, - threshold: row.threshold, - reward_points: row.reward_points, - enabled: row.enabled, - sort_order: row.sort_order, - created_by: row.created_by.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_by: row.updated_by.clone(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_profile_recharge_product_config_snapshot_from_row( - row: &ProfileRechargeProductConfig, -) -> RuntimeProfileRechargeProductConfigSnapshot { - RuntimeProfileRechargeProductConfigSnapshot { - product_id: row.product_id.clone(), - title: row.title.clone(), - price_cents: row.price_cents, - kind: row.kind, - points_amount: row.points_amount, - bonus_points: row.bonus_points, - duration_days: row.duration_days, - badge_label: row.badge_label.clone(), - description: row.description.clone(), - tier: row.tier, - membership_period_points: row.membership_period_points, - membership_period_days: row.membership_period_days, - membership_queue_limit: row.membership_queue_limit, - membership_discount_bps: row.membership_discount_bps, - enabled: row.enabled, - sort_order: row.sort_order, - created_by: row.created_by.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_by: row.updated_by.clone(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_profile_recharge_product_snapshot_from_config_row( - row: &ProfileRechargeProductConfig, -) -> RuntimeProfileRechargeProductSnapshot { - RuntimeProfileRechargeProductSnapshot { - product_id: row.product_id.clone(), - title: row.title.clone(), - price_cents: row.price_cents, - kind: row.kind, - points_amount: row.points_amount, - bonus_points: row.bonus_points, - duration_days: row.duration_days, - badge_label: row.badge_label.clone(), - description: row.description.clone(), - tier: row.tier, - membership_period_points: row.membership_period_points, - membership_period_days: row.membership_period_days, - membership_queue_limit: row.membership_queue_limit, - membership_discount_bps: row.membership_discount_bps, - } -} - -fn build_profile_recharge_order_snapshot_from_row( - row: &ProfileRechargeOrder, -) -> RuntimeProfileRechargeOrderSnapshot { - RuntimeProfileRechargeOrderSnapshot { - order_id: row.order_id.clone(), - user_id: row.user_id.clone(), - product_id: row.product_id.clone(), - product_title: row.product_title.clone(), - kind: row.kind, - amount_cents: row.amount_cents, - status: row.status, - payment_channel: row.payment_channel.clone(), - paid_at_micros: row.paid_at.map(|value| value.to_micros_since_unix_epoch()), - provider_transaction_id: row.provider_transaction_id.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - points_delta: row.points_delta, - membership_expires_at_micros: row - .membership_expires_at - .map(|value| value.to_micros_since_unix_epoch()), - expired_at_micros: row - .expired_at - .map(|value| value.to_micros_since_unix_epoch()), - expiration_checked_at_micros: row - .expiration_checked_at - .map(|value| value.to_micros_since_unix_epoch()), - expiration_provider_state: row.expiration_provider_state.clone(), - expiration_last_error: row.expiration_last_error.clone(), - } -} - -fn build_profile_recharge_refund_snapshot_from_row( - row: &ProfileRechargeRefund, -) -> RuntimeProfileRechargeRefundSnapshot { - RuntimeProfileRechargeRefundSnapshot { - out_refund_no: row.out_refund_no.clone(), - provider_refund_id: row.provider_refund_id.clone(), - order_id: row.order_id.clone(), - provider_transaction_id: row.provider_transaction_id.clone(), - user_id: row.user_id.clone(), - provider_status: row.provider_status, - total_cents: row.total_cents, - refund_cents: row.refund_cents, - payer_total_cents: row.payer_total_cents, - payer_refund_cents: row.payer_refund_cents, - success_at_micros: row - .success_at - .map(|value| value.to_micros_since_unix_epoch()), - first_observed_at_micros: row.first_observed_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - last_observation_source: row.last_observation_source, - last_observation_id: row.last_observation_id.clone(), - order_settled_at_micros: row - .order_settled_at - .map(|value| value.to_micros_since_unix_epoch()), - target_recovery_points: row.target_recovery_points, - recovered_points: row.recovered_points, - unrecovered_points: row.unrecovered_points, - recovery_status: row.recovery_status, - last_recovery_ledger_id: row.last_recovery_ledger_id.clone(), - last_error_code: row.last_error_code.clone(), - manual_review_resolved_by_admin_user_id: row - .manual_review_resolved_by_admin_user_id - .clone(), - manual_review_resolution_reason: row.manual_review_resolution_reason.clone(), - manual_review_resolved_at_micros: row - .manual_review_resolved_at - .map(|value| value.to_micros_since_unix_epoch()), - manual_review_resolved_error_code: row.manual_review_resolved_error_code.clone(), - } -} - -fn build_profile_recharge_refund_hold_snapshot_from_row( - row: &ProfileRechargeRefundHold, -) -> RuntimeProfileRechargeRefundHoldSnapshot { - RuntimeProfileRechargeRefundHoldSnapshot { - out_refund_no: row.out_refund_no.clone(), - order_id: row.order_id.clone(), - user_id: row.user_id.clone(), - refund_cents: row.refund_cents, - held_points: row.held_points, - status: row.status, - admin_user_id: row.admin_user_id.clone(), - reason: row.reason.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - settled_at_micros: row - .settled_at - .map(|value| value.to_micros_since_unix_epoch()), - released_at_micros: row - .released_at - .map(|value| value.to_micros_since_unix_epoch()), - released_by_admin_user_id: row.released_by_admin_user_id.clone(), - release_reason: row.release_reason.clone(), - } -} - -fn build_profile_wallet_manual_restriction_snapshot_from_row( - row: &ProfileWalletManualRestriction, -) -> RuntimeProfileWalletManualRestrictionSnapshot { - RuntimeProfileWalletManualRestrictionSnapshot { - user_id: row.user_id.clone(), - frozen: row.frozen, - reason: row.reason.clone(), - created_by_admin_user_id: row.created_by_admin_user_id.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_by_admin_user_id: row.updated_by_admin_user_id.clone(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -#[cfg(test)] -fn build_profile_recharge_refund_observation_snapshot_from_row( - row: &ProfileRechargeRefundObservation, -) -> RuntimeProfileRechargeRefundObservationSnapshot { - RuntimeProfileRechargeRefundObservationSnapshot { - observation_id: row.observation_id.clone(), - out_refund_no: row.out_refund_no.clone(), - provider_refund_id: row.provider_refund_id.clone(), - order_id: row.order_id.clone(), - provider_transaction_id: row.provider_transaction_id.clone(), - source: row.source, - provider_status: row.provider_status, - total_cents: row.total_cents, - refund_cents: row.refund_cents, - payer_total_cents: row.payer_total_cents, - payer_refund_cents: row.payer_refund_cents, - success_at_micros: row - .success_at - .map(|value| value.to_micros_since_unix_epoch()), - notification_ref: row.notification_ref.clone(), - payload_fingerprint: row.payload_fingerprint.clone(), - resolution_code: row.resolution_code.clone(), - observed_at_micros: row.observed_at.to_micros_since_unix_epoch(), - } -} - -fn build_profile_recharge_order_refund_settlement_snapshot_from_row( - row: &ProfileRechargeOrderRefundSettlement, -) -> RuntimeProfileRechargeOrderRefundSettlementSnapshot { - RuntimeProfileRechargeOrderRefundSettlementSnapshot { - order_id: row.order_id.clone(), - user_id: row.user_id.clone(), - successful_refund_count: row.successful_refund_count, - cumulative_success_refund_cents: row.cumulative_success_refund_cents, - target_recovery_points: row.target_recovery_points, - recovered_points: row.recovered_points, - unrecovered_points: row.unrecovered_points, - recovery_status: row.recovery_status, - wallet_frozen: row.wallet_frozen, - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_profile_recharge_refund_bill_checkpoint_snapshot_from_row( - row: &ProfileRechargeRefundBillCheckpoint, -) -> RuntimeProfileRechargeRefundBillCheckpointSnapshot { - RuntimeProfileRechargeRefundBillCheckpointSnapshot { - checkpoint_id: row.checkpoint_id.clone(), - bill_date: row.bill_date.clone(), - bill_hash: row.bill_hash.clone(), - processed_refund_count: row.processed_refund_count, - completed_at_micros: row.completed_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_profile_recharge_order_expiration_schedule_snapshot_from_row( - row: &ProfileRechargeOrderExpirationSchedule, -) -> RuntimeProfileRechargeOrderExpirationScheduleSnapshot { - RuntimeProfileRechargeOrderExpirationScheduleSnapshot { - order_id: row.order_id.clone(), - user_id: row.user_id.clone(), - scheduled_at_micros: row.scheduled_at.to_micros_since_unix_epoch(), - lease_owner: row.lease_owner.clone(), - lease_expires_at_micros: row - .lease_expires_at - .map(|value| value.to_micros_since_unix_epoch()), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_profile_feedback_submission_snapshot_from_row( - row: &ProfileFeedbackSubmission, -) -> RuntimeProfileFeedbackSubmissionSnapshot { - RuntimeProfileFeedbackSubmissionSnapshot { - feedback_id: row.feedback_id.clone(), - user_id: row.user_id.clone(), - description: row.description.clone(), - contact_phone: row.contact_phone.clone(), - evidence_json: row.evidence_json.clone(), - status: row.status, - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_profile_played_world_snapshot_from_row( - row: &ProfilePlayedWorld, -) -> RuntimeProfilePlayedWorldSnapshot { - RuntimeProfilePlayedWorldSnapshot { - played_world_id: row.played_world_id.clone(), - user_id: row.user_id.clone(), - world_key: row.world_key.clone(), - owner_user_id: row.owner_user_id.clone(), - profile_id: row.profile_id.clone(), - world_type: row.world_type.clone(), - world_title: row.world_title.clone(), - world_subtitle: row.world_subtitle.clone(), - first_played_at_micros: row.first_played_at.to_micros_since_unix_epoch(), - last_played_at_micros: row.last_played_at.to_micros_since_unix_epoch(), - last_observed_play_time_ms: row.last_observed_play_time_ms, - } -} From 61d4db4610d40b222a98ad281b0f2883593b1819 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:49:43 +0800 Subject: [PATCH 010/232] =?UTF-8?q?feat(=E4=BC=9A=E5=91=98):=20=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?=E8=A1=A8=20profile=5Fmembership=5Fplan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spacetime-module:新增 profile_membership_plan 表,主键为档位枚举 RuntimeProfileMembershipPlan,一行一档承载月价 / 年价 / 每期额度 / 模型权限 / 并发上限 - migration.rs:表目录登记 profile_membership_plan - spacetime-client:重新生成绑定,新增目录表与档位 / 模型权限枚举 - docs:后端数据契约新增 profile_membership_plan 说明,并把表定义源码路径修正为 runtime/active/profile.rs Co-authored-by: Junie --- ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 69 +++--- .../spacetime-client/src/module_bindings.rs | 32 +++ .../profile_membership_plan_table.rs | 234 ++++++++++++++++++ .../profile_membership_plan_type.rs | 95 +++++++ ...me_profile_membership_model_access_type.rs | 18 ++ .../runtime_profile_membership_plan_type.rs | 24 ++ .../crates/spacetime-module/src/migration.rs | 1 + .../src/runtime/active/profile.rs | 20 ++ 8 files changed, 463 insertions(+), 30 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_membership_plan_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_membership_plan_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_model_access_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_type.rs diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index fb31bb682..702e285f3 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -697,144 +697,153 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 ### `profile_dashboard_state` - Rust 结构体:`ProfileDashboardState` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### `profile_daily_free_points` - Rust 结构体:`ProfileDailyFreePoints` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:每日免费泥点事实源。`day_key` 使用北京时间业务日,基础发放量读取 `profile_wallet_config.daily_free_points_per_day`(未配置时默认 `20`),`remaining_points` 保存当日剩余额度;跨业务日退款的每日免费消费部分会叠加到退款当日,使 `granted_points` 和 `remaining_points` 可暂时超过当前基础发放量,下一业务日首次触达时旧余额与叠加量一并失效并按当时最新配置重置。 ### `profile_feedback_submission` - Rust 结构体:`ProfileFeedbackSubmission` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### `profile_invite_code` - Rust 结构体:`ProfileInviteCode` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 生效时间:`starts_at` / `expires_at` 均可为空;两者同时存在时必须满足 `starts_at < expires_at`。开始时刻计入有效区间,截止时刻不计入有效区间。 ### `profile_code_operation` - Rust 结构体:`ProfileCodeOperation` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:后台兑换码 / 邀请码的持久操作记录,记录新增、更新、停用的码值、操作人和操作时间。 ### `profile_membership` - Rust 结构体:`ProfileMembership` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:会员有效期和当前周期限时泥点事实源。`started_at/expires_at` 表示会员有效期,`cycle_started_at/cycle_resets_at/cycle_period_days` 表示当前周期,`cycle_granted_points/cycle_remaining_points` 表示当前周期已发放和剩余限时泥点。 +### `profile_membership_plan` + +- Rust 结构体:`ProfileMembershipPlan` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` +- 作用:会员计划目录真相源,**主键是档位枚举** `RuntimeProfileMembershipPlan`(`Normal` / `Starter` / `Plus` / `Pro` / `Max`),一行一档,供后台按档位直接改价和改权益。 +- 字段:`month_price_cents` 与 `year_price_cents` 是两个独立可配置价格(年价不做月价 ×10 推导);`period_points` 是每期(一个自然月)额度,年付 = 12 次同额度发放;`model_access` 与 `concurrent_job_limit` 是档位权益,并发上限 `128` 表示“不设上限”。 +- 索引:主键 `plan`。 +- 说明:`Normal` 是不可购买的非会员占位档位(`enabled = false`);模型权限与并发上限当前只落字段、只用于展示,服务端不做拦截。 + ### `profile_recharge_product_config` - Rust 结构体:`ProfileRechargeProductConfig` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:泥点和会员充值商品配置真相源,供充值中心展示、下单校验、支付确认和后台“充值商品”页维护;当前公开充值中心只展示四档泥点商品,会员商品配置留存但不公开购买或升级入口。 - 字段补充:会员商品追加 `membership_period_points`、`membership_period_days`、`membership_queue_limit`、`membership_discount_bps`;泥点商品这些字段必须为 `0`。 ### `profile_played_world` - Rust 结构体:`ProfilePlayedWorld` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### `profile_recharge_order` - Rust 结构体:`ProfileRechargeOrder` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:账户充值订单事实源。`status` 包含 `pending`、`paid`、`failed`、`closed`、`refunded`、`expired`;过期补偿字段 `expired_at`、`expiration_checked_at`、`expiration_provider_state`、`expiration_last_error` 用于记录本地过期和微信查单结果。 ### `profile_recharge_refund` - Rust 结构体:`ProfileRechargeRefund` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:普通微信支付 V3 退款单聚合。以 `out_refund_no` 为主键、`provider_refund_id` 唯一,保存原订单/微信支付单、四个分金额、微信状态、最近观察、权益目标/已回收/未回收量和人工处理错误码。管理员读取契约同步暴露微信交易号、订单总额和人工复核获批错误码。交易号或订单总额冲突经管理员确认归属后,追加保存处理管理员、非空原因、处理时间和本次获批的错误码;四字段只写一次,作为重新执行正式结算的审计事实。 - 索引:`by_profile_recharge_refund_order_id`、`by_profile_recharge_refund_status_updated_at`。 ### `profile_recharge_refund_observation` - Rust 结构体:`ProfileRechargeRefundObservation` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:退款事实的追加观察记录。保存 callback / api_request / query / trade_bill 来源、按“来源 + 事实指纹”稳定生成的 observation ID、金额、状态、脱敏通知引用、事实指纹和 resolution code;同一事实从不同来源进入时使用不同 ID,不保存回调密文、签名、密钥、原始 CSV 或短时下载 URL。 - 索引:`by_profile_recharge_refund_observation_out_refund_no`、`by_profile_recharge_refund_observation_order_id`。 ### `profile_recharge_order_refund_settlement` - Rust 结构体:`ProfileRechargeOrderRefundSettlement` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:原充值订单维度的退款与权益结算摘要,保存累计成功退款金额、目标/已回收/未回收泥点、权益状态和钱包冻结事实。部分退款不改订单终态;累计全额才把订单改为 `refunded`。 - 索引:主键 `order_id`,`by_profile_recharge_order_refund_settlement_user_id` 用于钱包消费前检查退款欠款。 ### `profile_recharge_refund_hold` - Rust 结构体:`ProfileRechargeRefundHold` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:后台主动退款调用微信前的永久泥点占用。活动占用保护退款所需泥点但不改钱包总额;匹配退款成功后结算,关闭或确认未创建的退款释放。相同 `out_refund_no` 只允许内容完全一致的幂等重放。 - 索引:主键 `out_refund_no`,`by_profile_recharge_refund_hold_order_id`、`by_profile_recharge_refund_hold_user_id`、`by_profile_recharge_refund_hold_status`。 ### `profile_wallet_manual_restriction` - Rust 结构体:`ProfileWalletManualRestriction` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:后台人工钱包冻结当前态,保存冻结原因、创建/更新管理员和时间。它不承载退款欠账;退款欠账仍来自订单退款 settlement,两个来源任一有效都阻断普通消费。 - 索引:主键 `user_id`。 ### `profile_recharge_refund_bill_checkpoint` - Rust 结构体:`ProfileRechargeRefundBillCheckpoint` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:按交易账单日期记录已完成的退款 reconciliation,保存账单 SHA1(确认稳定无账单时保存 `NO_STATEMENT_EXIST`)、处理退款行数和完成时间。任一退款行失败时不写完成 checkpoint,成功前缀依赖 observation 幂等重放;重复下载、多实例执行或进程重启不会重复回收权益。 ### `profile_recharge_order_expiration_schedule` - Rust 结构体:`ProfileRechargeOrderExpirationSchedule` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:旧普通微信充值订单到期查单调度表,保留 schema 兼容但当前不再写入;当前过期路径改由原生 scheduled 表 `profile_recharge_order_expiration_timer` 触发。 ### `profile_recharge_order_expiration_timer` - Rust 结构体:`ProfileRechargeOrderExpirationTimer` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:充值订单原生 scheduled 表,`scheduled_at` 到点触发 `expire_profile_recharge_order_timer`;`order_id` 唯一,支付成功、本地关闭或 scheduled reducer 执行后删除对应行。 ### `profile_redeem_code` - Rust 结构体:`ProfileRedeemCode` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 生效时间:复用邀请码时间窗口语义,`starts_at` / `expires_at` 均可为空;两者同时存在时必须满足 `starts_at < expires_at`。开始时刻计入有效区间,截止时刻不计入有效区间。用户兑换时以后端接收的 `redeemed_at_micros` 判定:未到开始时间拒绝为“兑换码未生效”,到达或超过截止时间拒绝为“兑换码已过期”。 - 后台契约:`AdminUpsertProfileRedeemCodeRequest` 通过可空 `startsAt` / `expiresAt` 接收 RFC3339 时间,列表与保存响应同步返回这两个字段;后台页只负责输入、回填和显示,真正兑换判定留在后端事务路径。`reward_points` 是奖励泥点(兑换成功后按 `redeem_code_reward` 流水进入泥点钱包),不是金额;后台奖励输入与列表必须带泥点单位。 ### `profile_redeem_code_usage` - Rust 结构体:`ProfileRedeemCodeUsage` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### `profile_referral_relation` - Rust 结构体:`ProfileReferralRelation` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### `profile_save_archive` - Rust 结构体:`ProfileSaveArchive` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### `profile_task_config` - Rust 结构体:`ProfileTaskConfig` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### `profile_task_progress` - Rust 结构体:`ProfileTaskProgress` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### `profile_task_reward_claim` - Rust 结构体:`ProfileTaskRewardClaim` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### `profile_wallet_ledger` @@ -852,7 +861,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 ### `asset_operation_wallet_settlement` - Rust 结构体:`AssetOperationWalletSettlement` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 说明:资产操作 consume/refund 配对结算事实表,主键为 consume ledger ID,并保存配对 refund ledger、用户、金额和结算时间。退款先到且 consume 尚不可见时,该表作为持久化取消 intent;迟到 consume 必须检测该行并拒绝扣费,避免 worker 崩溃重领期间双扣。 - 索引:主键 `consume_ledger_id`。 @@ -866,18 +875,18 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 ### `profile_wallet_config` - Rust 结构体:`ProfileWalletConfig` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:账号钱包全局配置真相源,当前维护新账号注册初始泥点数;表为空时业务回退 `100` 泥点。 ### `public_work_like` - Rust 结构体:`PublicWorkLike` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### `public_work_play_daily_stat` - Rust 结构体:`PublicWorkPlayDailyStat` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### api-server 长期订阅读模型 @@ -913,7 +922,7 @@ private `asset_object` 不进入长期订阅;安全判断通过受限 procedur ### `tracking_daily_stat` - Rust 结构体:`TrackingDailyStat` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 写入:由单条或批量 tracking procedure 在同一事务中随 `tracking_event` 更新,作为运营查询和个人任务进度的聚合投影。 ### `agc_tracking_event` @@ -927,7 +936,7 @@ private `asset_object` 不进入长期订阅;安全判断通过受限 procedur ### `tracking_event` - Rust 结构体:`TrackingEvent` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 写入:关键业务埋点同步调用单条 procedure;普通 HTTP route tracking 由 `api-server` 本机 outbox 批量调用 `record_tracking_events_and_return`。outbox 到达批量阈值时先封存 active 文件并切新 active,后台 worker 异步 flush sealed 文件,HTTP 请求线程不等待 SpacetimeDB。`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件,`MAX_BYTES` 只做磁盘保护阈值。`event_id` 必须稳定且全局唯一,批量重试时用唯一索引做幂等跳过。 - 外部 API 失败:`event_key = external_api_call_failure` 使用同一张表落库;它是供应商失败审计事实,不新增 SpacetimeDB 表,查询时按 `module_key = 'external-api'` 或 `scope_kind = module AND scope_id = ''` 过滤。 diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 0e2874cae..dfb4367c4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -548,6 +548,8 @@ pub mod profile_feedback_submission_table; pub mod profile_feedback_submission_type; pub mod profile_invite_code_table; pub mod profile_invite_code_type; +pub mod profile_membership_plan_table; +pub mod profile_membership_plan_type; pub mod profile_membership_table; pub mod profile_membership_type; pub mod profile_played_world_table; @@ -647,6 +649,8 @@ pub mod runtime_profile_invite_code_admin_procedure_result_type; pub mod runtime_profile_invite_code_admin_upsert_input_type; pub mod runtime_profile_invite_code_snapshot_type; pub mod runtime_profile_membership_benefit_snapshot_type; +pub mod runtime_profile_membership_model_access_type; +pub mod runtime_profile_membership_plan_type; pub mod runtime_profile_membership_snapshot_type; pub mod runtime_profile_membership_status_type; pub mod runtime_profile_membership_tier_type; @@ -1349,6 +1353,8 @@ pub use profile_feedback_submission_table::*; pub use profile_feedback_submission_type::ProfileFeedbackSubmission; pub use profile_invite_code_table::*; pub use profile_invite_code_type::ProfileInviteCode; +pub use profile_membership_plan_table::*; +pub use profile_membership_plan_type::ProfileMembershipPlan; pub use profile_membership_table::*; pub use profile_membership_type::ProfileMembership; pub use profile_played_world_table::*; @@ -1448,6 +1454,8 @@ pub use runtime_profile_invite_code_admin_procedure_result_type::RuntimeProfileI pub use runtime_profile_invite_code_admin_upsert_input_type::RuntimeProfileInviteCodeAdminUpsertInput; pub use runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot; pub use runtime_profile_membership_benefit_snapshot_type::RuntimeProfileMembershipBenefitSnapshot; +pub use runtime_profile_membership_model_access_type::RuntimeProfileMembershipModelAccess; +pub use runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; pub use runtime_profile_membership_snapshot_type::RuntimeProfileMembershipSnapshot; pub use runtime_profile_membership_status_type::RuntimeProfileMembershipStatus; pub use runtime_profile_membership_tier_type::RuntimeProfileMembershipTier; @@ -1774,6 +1782,7 @@ pub struct DbUpdate { profile_feedback_submission: __sdk::TableUpdate, profile_invite_code: __sdk::TableUpdate, profile_membership: __sdk::TableUpdate, + profile_membership_plan: __sdk::TableUpdate, profile_played_world: __sdk::TableUpdate, profile_recharge_order: __sdk::TableUpdate, profile_recharge_order_expiration_schedule: @@ -2017,6 +2026,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "profile_membership" => db_update .profile_membership .append(profile_membership_table::parse_table_update(table_update)?), + "profile_membership_plan" => db_update.profile_membership_plan.append( + profile_membership_plan_table::parse_table_update(table_update)?, + ), "profile_played_world" => db_update.profile_played_world.append( profile_played_world_table::parse_table_update(table_update)?, ), @@ -2446,6 +2458,12 @@ impl __sdk::DbUpdate for DbUpdate { &self.profile_membership, ) .with_updates_by_pk(|row| &row.user_id); + diff.profile_membership_plan = cache + .apply_diff_to_table::( + "profile_membership_plan", + &self.profile_membership_plan, + ) + .with_updates_by_pk(|row| &row.plan); diff.profile_played_world = cache .apply_diff_to_table::( "profile_played_world", @@ -2789,6 +2807,9 @@ impl __sdk::DbUpdate for DbUpdate { "profile_membership" => db_update .profile_membership .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "profile_membership_plan" => db_update + .profile_membership_plan + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "profile_played_world" => db_update .profile_played_world .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3063,6 +3084,9 @@ impl __sdk::DbUpdate for DbUpdate { "profile_membership" => db_update .profile_membership .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "profile_membership_plan" => db_update + .profile_membership_plan + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "profile_played_world" => db_update .profile_played_world .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -3231,6 +3255,7 @@ pub struct AppliedDiff<'r> { profile_feedback_submission: __sdk::TableAppliedDiff<'r, ProfileFeedbackSubmission>, profile_invite_code: __sdk::TableAppliedDiff<'r, ProfileInviteCode>, profile_membership: __sdk::TableAppliedDiff<'r, ProfileMembership>, + profile_membership_plan: __sdk::TableAppliedDiff<'r, ProfileMembershipPlan>, profile_played_world: __sdk::TableAppliedDiff<'r, ProfilePlayedWorld>, profile_recharge_order: __sdk::TableAppliedDiff<'r, ProfileRechargeOrder>, profile_recharge_order_expiration_schedule: @@ -3552,6 +3577,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.profile_membership, event, ); + callbacks.invoke_table_row_callbacks::( + "profile_membership_plan", + &self.profile_membership_plan, + event, + ); callbacks.invoke_table_row_callbacks::( "profile_played_world", &self.profile_played_world, @@ -4423,6 +4453,7 @@ impl __sdk::SpacetimeModule for RemoteModule { profile_feedback_submission_table::register_table(client_cache); profile_invite_code_table::register_table(client_cache); profile_membership_table::register_table(client_cache); + profile_membership_plan_table::register_table(client_cache); profile_played_world_table::register_table(client_cache); profile_recharge_order_table::register_table(client_cache); profile_recharge_order_expiration_schedule_table::register_table(client_cache); @@ -4512,6 +4543,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "profile_feedback_submission", "profile_invite_code", "profile_membership", + "profile_membership_plan", "profile_played_world", "profile_recharge_order", "profile_recharge_order_expiration_schedule", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_plan_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_plan_table.rs new file mode 100644 index 000000000..b9560bde3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_plan_table.rs @@ -0,0 +1,234 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::profile_membership_plan_type::ProfileMembershipPlan; +use super::runtime_profile_membership_model_access_type::RuntimeProfileMembershipModelAccess; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `profile_membership_plan`. +/// +/// Obtain a handle from the [`ProfileMembershipPlanTableAccess::profile_membership_plan`] method on [`super::RemoteTables`], +/// like `ctx.db.profile_membership_plan()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_membership_plan().on_insert(...)`. +pub struct ProfileMembershipPlanTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `profile_membership_plan`. +pub struct ProfileMembershipPlanTableAccessor; + +impl __sdk::TableAccessor for ProfileMembershipPlanTableAccessor { + type Row = ProfileMembershipPlan; + type Handle<'db> = ProfileMembershipPlanTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.profile_membership_plan() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `profile_membership_plan`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait ProfileMembershipPlanTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`ProfileMembershipPlanTableHandle`], which mediates access to the table `profile_membership_plan`. + fn profile_membership_plan(&self) -> ProfileMembershipPlanTableHandle<'_>; +} + +impl ProfileMembershipPlanTableAccess for super::RemoteTables { + fn profile_membership_plan(&self) -> ProfileMembershipPlanTableHandle<'_> { + ProfileMembershipPlanTableHandle { + imp: self + .imp + .get_table::("profile_membership_plan"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct ProfileMembershipPlanInsertCallbackId(__sdk::CallbackId); +pub struct ProfileMembershipPlanDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for ProfileMembershipPlanTableHandle<'ctx> { + type Row = ProfileMembershipPlan; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for ProfileMembershipPlanTableHandle<'ctx> { + type Row = ProfileMembershipPlan; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = ProfileMembershipPlanInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileMembershipPlanInsertCallbackId { + ProfileMembershipPlanInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ProfileMembershipPlanInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = ProfileMembershipPlanDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileMembershipPlanDeleteCallbackId { + ProfileMembershipPlanDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ProfileMembershipPlanDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for ProfileMembershipPlanTableHandle<'ctx> { + type InsertCallbackId = ProfileMembershipPlanInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileMembershipPlanInsertCallbackId { + ProfileMembershipPlanInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ProfileMembershipPlanInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for ProfileMembershipPlanTableHandle<'ctx> { + type DeleteCallbackId = ProfileMembershipPlanDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileMembershipPlanDeleteCallbackId { + ProfileMembershipPlanDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ProfileMembershipPlanDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct ProfileMembershipPlanUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for ProfileMembershipPlanTableHandle<'ctx> { + type UpdateCallbackId = ProfileMembershipPlanUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ProfileMembershipPlanUpdateCallbackId { + ProfileMembershipPlanUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ProfileMembershipPlanUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for ProfileMembershipPlanTableHandle<'ctx> { + type UpdateCallbackId = ProfileMembershipPlanUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ProfileMembershipPlanUpdateCallbackId { + ProfileMembershipPlanUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ProfileMembershipPlanUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `plan` unique index on the table `profile_membership_plan`, +/// which allows point queries on the field of the same name +/// via the [`ProfileMembershipPlanPlanUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_membership_plan().plan().find(...)`. +pub struct ProfileMembershipPlanPlanUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ProfileMembershipPlanTableHandle<'ctx> { + /// Get a handle on the `plan` unique index on the table `profile_membership_plan`. + pub fn plan(&self) -> ProfileMembershipPlanPlanUnique<'ctx> { + ProfileMembershipPlanPlanUnique { + imp: self + .imp + .get_unique_constraint::("plan"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ProfileMembershipPlanPlanUnique<'ctx> { + /// Find the subscribed row whose `plan` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &RuntimeProfileMembershipPlan) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("profile_membership_plan"); + _table.add_unique_constraint::("plan", |row| &row.plan); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `ProfileMembershipPlan`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait profile_membership_planQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ProfileMembershipPlan`. + fn profile_membership_plan(&self) -> __sdk::__query_builder::Table; +} + +impl profile_membership_planQueryTableAccess for __sdk::QueryTableAccessor { + fn profile_membership_plan(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("profile_membership_plan") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_plan_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_plan_type.rs new file mode 100644 index 000000000..848ab50fc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_plan_type.rs @@ -0,0 +1,95 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_membership_model_access_type::RuntimeProfileMembershipModelAccess; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ProfileMembershipPlan { + 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, + pub created_by: String, + pub created_at: __sdk::Timestamp, + pub updated_by: String, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for ProfileMembershipPlan { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ProfileMembershipPlan`. +/// +/// Provides typed access to columns for query building. +pub struct ProfileMembershipPlanCols { + pub plan: __sdk::__query_builder::Col, + pub title: __sdk::__query_builder::Col, + pub rank: __sdk::__query_builder::Col, + pub month_price_cents: __sdk::__query_builder::Col, + pub year_price_cents: __sdk::__query_builder::Col, + pub period_points: __sdk::__query_builder::Col, + pub model_access: + __sdk::__query_builder::Col, + pub concurrent_job_limit: __sdk::__query_builder::Col, + pub sort_order: __sdk::__query_builder::Col, + pub enabled: __sdk::__query_builder::Col, + pub created_by: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_by: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for ProfileMembershipPlan { + type Cols = ProfileMembershipPlanCols; + fn cols(table_name: &'static str) -> Self::Cols { + ProfileMembershipPlanCols { + plan: __sdk::__query_builder::Col::new(table_name, "plan"), + title: __sdk::__query_builder::Col::new(table_name, "title"), + rank: __sdk::__query_builder::Col::new(table_name, "rank"), + month_price_cents: __sdk::__query_builder::Col::new(table_name, "month_price_cents"), + year_price_cents: __sdk::__query_builder::Col::new(table_name, "year_price_cents"), + period_points: __sdk::__query_builder::Col::new(table_name, "period_points"), + model_access: __sdk::__query_builder::Col::new(table_name, "model_access"), + concurrent_job_limit: __sdk::__query_builder::Col::new( + table_name, + "concurrent_job_limit", + ), + sort_order: __sdk::__query_builder::Col::new(table_name, "sort_order"), + enabled: __sdk::__query_builder::Col::new(table_name, "enabled"), + created_by: __sdk::__query_builder::Col::new(table_name, "created_by"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_by: __sdk::__query_builder::Col::new(table_name, "updated_by"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `ProfileMembershipPlan`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ProfileMembershipPlanIxCols { + pub plan: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ProfileMembershipPlan { + type IxCols = ProfileMembershipPlanIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ProfileMembershipPlanIxCols { + plan: __sdk::__query_builder::IxCol::new(table_name, "plan"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ProfileMembershipPlan {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_model_access_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_model_access_type.rs new file mode 100644 index 000000000..10eac3cf3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_model_access_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileMembershipModelAccess { + Basic, + + Full, +} + +impl __sdk::InModule for RuntimeProfileMembershipModelAccess { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_type.rs new file mode 100644 index 000000000..9fc0c3c78 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileMembershipPlan { + Normal, + + Starter, + + Plus, + + Pro, + + Max, +} + +impl __sdk::InModule for RuntimeProfileMembershipPlan { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index ba1738869..5018f86d8 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -188,6 +188,7 @@ macro_rules! migration_tables { public_work_like, // profile_membership / profile_recharge_product_config carry membership-cycle point fields. profile_membership, + profile_membership_plan, profile_recharge_product_config, profile_recharge_order, profile_recharge_refund, 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 39b91746a..03b8b388a 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -486,6 +486,26 @@ pub struct ProfileRechargeProductConfig { pub(crate) membership_discount_bps: u32, } +#[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, +} + #[spacetimedb::table( accessor = profile_recharge_order, index(accessor = by_profile_recharge_order_user_id, btree(columns = [user_id])), From 103f3b548aa59d7e8202f7b2cbb1637b16893cdb 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:55:31 +0800 Subject: [PATCH 011/232] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E6=B8=85?= =?UTF-8?q?=E9=80=80=E4=BC=9A=E5=91=98=E9=81=97=E7=95=99=E6=A1=A3=E4=BD=8D?= =?UTF-8?q?=E4=B8=8E=E5=95=86=E5=93=81=E6=9D=83=E7=9B=8A=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 RuntimeProfileMembershipTier 枚举与会员权益快照/记录。 - 会员快照与记录的 tier 改为 plan 枚举,新增 cycle_index / cycle_count / cycle_kind。 - 充点商品快照/记录与配置快照/记录移除 tier 及四个 membership_* 字段。 - 充值中心快照/记录改用 membership_plans,取代 membership_products 与 benefits。 - 充点商品重播种为六档并取消首充赠送,删除会员商品构造与档位查表 helper。 - 后台充值商品 upsert 只接受泥点商品,会员商品改由会员计划目录维护。 --- .../crates/module-runtime/src/application.rs | 43 +-- .../crates/module-runtime/src/commands.rs | 31 +- server-rs/crates/module-runtime/src/domain.rs | 103 +----- server-rs/crates/module-runtime/src/lib.rs | 294 +++--------------- 4 files changed, 67 insertions(+), 404 deletions(-) diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 19de95527..4779ec1fa 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -1052,16 +1052,7 @@ pub fn build_runtime_profile_recharge_center_record( .into_iter() .map(build_runtime_profile_recharge_product_record) .collect(), - membership_products: snapshot - .membership_products - .into_iter() - .map(build_runtime_profile_recharge_product_record) - .collect(), - benefits: snapshot - .benefits - .into_iter() - .map(build_runtime_profile_membership_benefit_record) - .collect(), + membership_plans: snapshot.membership_plans, latest_order: snapshot .latest_order .map(build_runtime_profile_recharge_order_record), @@ -1085,11 +1076,6 @@ pub fn build_runtime_profile_recharge_product_record( duration_days: snapshot.duration_days, badge_label: snapshot.badge_label, description: snapshot.description, - tier: snapshot.tier, - membership_period_points: snapshot.membership_period_points, - membership_period_days: snapshot.membership_period_days, - membership_queue_limit: snapshot.membership_queue_limit, - membership_discount_bps: snapshot.membership_discount_bps, } } @@ -1106,11 +1092,6 @@ pub fn build_runtime_profile_recharge_product_config_record( duration_days: snapshot.duration_days, badge_label: snapshot.badge_label, description: snapshot.description, - tier: snapshot.tier, - membership_period_points: snapshot.membership_period_points, - membership_period_days: snapshot.membership_period_days, - membership_queue_limit: snapshot.membership_queue_limit, - membership_discount_bps: snapshot.membership_discount_bps, enabled: snapshot.enabled, sort_order: snapshot.sort_order, created_by: snapshot.created_by, @@ -1122,29 +1103,13 @@ pub fn build_runtime_profile_recharge_product_config_record( } } -pub fn build_runtime_profile_membership_benefit_record( - snapshot: RuntimeProfileMembershipBenefitSnapshot, -) -> RuntimeProfileMembershipBenefitRecord { - RuntimeProfileMembershipBenefitRecord { - benefit_name: snapshot.benefit_name, - normal_value: snapshot.normal_value, - month_value: snapshot.month_value, - season_value: snapshot.season_value, - year_value: snapshot.year_value, - starter_value: snapshot.starter_value, - basic_value: snapshot.basic_value, - pro_value: snapshot.pro_value, - ultimate_value: snapshot.ultimate_value, - } -} - pub fn build_runtime_profile_membership_record( snapshot: RuntimeProfileMembershipSnapshot, ) -> RuntimeProfileMembershipRecord { RuntimeProfileMembershipRecord { user_id: snapshot.user_id, status: snapshot.status, - tier: snapshot.tier, + plan: snapshot.plan, started_at: snapshot.started_at_micros.map(format_utc_micros), started_at_micros: snapshot.started_at_micros, expires_at: snapshot.expires_at_micros.map(format_utc_micros), @@ -1157,7 +1122,9 @@ pub fn build_runtime_profile_membership_record( cycle_resets_at_micros: snapshot.cycle_resets_at_micros, cycle_granted_points: snapshot.cycle_granted_points, cycle_remaining_points: snapshot.cycle_remaining_points, - cycle_period_days: snapshot.cycle_period_days, + cycle_index: snapshot.cycle_index, + cycle_count: snapshot.cycle_count, + cycle_kind: snapshot.cycle_kind, } } diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 138bb2cf3..bab1dff70 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -392,11 +392,6 @@ pub fn build_runtime_profile_recharge_product_admin_upsert_input( duration_days: u32, badge_label: String, description: String, - tier: RuntimeProfileMembershipTier, - membership_period_points: u64, - membership_period_days: u32, - membership_queue_limit: u32, - membership_discount_bps: u32, enabled: bool, sort_order: i32, updated_at_micros: i64, @@ -414,28 +409,13 @@ pub fn build_runtime_profile_recharge_product_admin_upsert_input( if points_amount == 0 { return Err(RuntimeProfileFieldError::InvalidRechargeProductPoints); } - if duration_days != 0 - || tier != RuntimeProfileMembershipTier::Normal - || membership_period_points != 0 - || membership_period_days != 0 - || membership_queue_limit != 0 - || membership_discount_bps != 0 - { + if duration_days != 0 || bonus_points != 0 { return Err(RuntimeProfileFieldError::InvalidRechargeProductTier); } } + // 中文注释:会员商品已迁移到 profile_membership_plan 目录,这里只保留泥点商品。 RuntimeProfileRechargeProductKind::Membership => { - if duration_days == 0 { - return Err(RuntimeProfileFieldError::InvalidRechargeProductDuration); - } - if points_amount != 0 - || bonus_points != 0 - || tier == RuntimeProfileMembershipTier::Normal - || membership_period_points == 0 - || membership_period_days == 0 - { - return Err(RuntimeProfileFieldError::InvalidRechargeProductTier); - } + return Err(RuntimeProfileFieldError::InvalidRechargeProductTier); } } @@ -450,11 +430,6 @@ pub fn build_runtime_profile_recharge_product_admin_upsert_input( duration_days, badge_label: normalize_optional_string(Some(badge_label)).unwrap_or_default(), description: normalize_optional_string(Some(description)).unwrap_or_default(), - tier, - membership_period_points, - membership_period_days, - membership_queue_limit, - membership_discount_bps, enabled, sort_order, updated_at_micros, diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index be3856223..267ff368e 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -4,6 +4,8 @@ //! 纯领域事实与值对象;SpacetimeDB client 与 HTTP response 不进入本文件。 use serde::{Deserialize, Serialize}; + +use crate::RuntimeProfileMembershipPlanSnapshot; #[cfg(any())] use serde_json::Value; @@ -1229,37 +1231,10 @@ impl RuntimeProfileMembershipStatus { } } -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum RuntimeProfileMembershipTier { - Normal, - Month, - Season, - Year, - Starter, - Basic, - Pro, - Ultimate, -} - -impl RuntimeProfileMembershipTier { - pub fn as_str(&self) -> &'static str { - match self { - Self::Normal => "normal", - Self::Month => "month", - Self::Season => "season", - Self::Year => "year", - Self::Starter => "starter", - Self::Basic => "basic", - Self::Pro => "pro", - Self::Ultimate => "ultimate", - } - } -} - /// 会员档位身份。取代旧的 `RuntimeProfileMembershipTier`,作为唯一权威档位标识。 /// -/// 变体顺序即排序基准(`Normal < Starter < Plus < Pro < Max`),升级比较不再依赖旧枚举别名。 +/// 变体顺序即排序基准(`Normal < Starter < Plus < Pro < Max`)。对外 wire 只用 +/// [`RuntimeProfileMembershipPlan::as_str`] 产出的枚举 token。 #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimeProfileMembershipPlan { @@ -1514,11 +1489,6 @@ pub struct RuntimeProfileRechargeProductSnapshot { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: RuntimeProfileMembershipTier, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -1533,11 +1503,6 @@ pub struct RuntimeProfileRechargeProductConfigSnapshot { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: RuntimeProfileMembershipTier, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, pub enabled: bool, pub sort_order: i32, pub created_by: String, @@ -1546,26 +1511,12 @@ pub struct RuntimeProfileRechargeProductConfigSnapshot { pub updated_at_micros: i64, } -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct RuntimeProfileMembershipBenefitSnapshot { - pub benefit_name: String, - pub normal_value: String, - pub month_value: String, - pub season_value: String, - pub year_value: String, - pub starter_value: String, - pub basic_value: String, - pub pro_value: String, - pub ultimate_value: String, -} - #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileMembershipSnapshot { pub user_id: String, pub status: RuntimeProfileMembershipStatus, - pub tier: RuntimeProfileMembershipTier, + pub plan: RuntimeProfileMembershipPlan, pub started_at_micros: Option, pub expires_at_micros: Option, pub updated_at_micros: Option, @@ -1573,7 +1524,9 @@ pub struct RuntimeProfileMembershipSnapshot { pub cycle_resets_at_micros: Option, pub cycle_granted_points: u64, pub cycle_remaining_points: u64, - pub cycle_period_days: u32, + pub cycle_index: u32, + pub cycle_count: u32, + pub cycle_kind: RuntimeProfileMembershipCycleKind, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -1765,8 +1718,7 @@ pub struct RuntimeProfileRechargeCenterSnapshot { pub wallet_balance: u64, pub membership: RuntimeProfileMembershipSnapshot, pub point_products: Vec, - pub membership_products: Vec, - pub benefits: Vec, + pub membership_plans: Vec, pub latest_order: Option, pub has_points_recharged: bool, pub daily_free_points: RuntimeProfileDailyFreePointsSnapshot, @@ -1807,11 +1759,6 @@ pub struct RuntimeProfileRechargeProductAdminUpsertInput { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: RuntimeProfileMembershipTier, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, pub enabled: bool, pub sort_order: i32, pub updated_at_micros: i64, @@ -2628,11 +2575,6 @@ pub struct RuntimeProfileRechargeProductRecord { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: RuntimeProfileMembershipTier, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, } #[derive(Clone, Debug, PartialEq)] @@ -2646,11 +2588,6 @@ pub struct RuntimeProfileRechargeProductConfigRecord { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: RuntimeProfileMembershipTier, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, pub enabled: bool, pub sort_order: i32, pub created_by: String, @@ -2674,24 +2611,11 @@ pub struct RuntimeProfileWalletConfigRecord { pub daily_free_points_per_day: u64, } -#[derive(Clone, Debug, PartialEq)] -pub struct RuntimeProfileMembershipBenefitRecord { - pub benefit_name: String, - pub normal_value: String, - pub month_value: String, - pub season_value: String, - pub year_value: String, - pub starter_value: String, - pub basic_value: String, - pub pro_value: String, - pub ultimate_value: String, -} - #[derive(Clone, Debug, PartialEq)] pub struct RuntimeProfileMembershipRecord { pub user_id: String, pub status: RuntimeProfileMembershipStatus, - pub tier: RuntimeProfileMembershipTier, + pub plan: RuntimeProfileMembershipPlan, pub started_at: Option, pub started_at_micros: Option, pub expires_at: Option, @@ -2704,7 +2628,9 @@ pub struct RuntimeProfileMembershipRecord { pub cycle_resets_at_micros: Option, pub cycle_granted_points: u64, pub cycle_remaining_points: u64, - pub cycle_period_days: u32, + pub cycle_index: u32, + pub cycle_count: u32, + pub cycle_kind: RuntimeProfileMembershipCycleKind, } #[derive(Clone, Debug, PartialEq)] @@ -2739,8 +2665,7 @@ pub struct RuntimeProfileRechargeCenterRecord { pub wallet_balance: u64, pub membership: RuntimeProfileMembershipRecord, pub point_products: Vec, - pub membership_products: Vec, - pub benefits: Vec, + pub membership_plans: Vec, pub latest_order: Option, pub has_points_recharged: bool, pub daily_free_points: RuntimeProfileDailyFreePointsRecord, diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 8c0ddb80a..ce5c04494 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -21,8 +21,6 @@ pub use membership::*; use shared_kernel::format_rfc3339 as format_shared_rfc3339; use time::OffsetDateTime; -pub const PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS: u32 = 30; - pub fn format_utc_micros(micros: i64) -> String { let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(micros) * 1_000) .unwrap_or(OffsetDateTime::UNIX_EPOCH); @@ -31,166 +29,12 @@ pub fn format_utc_micros(micros: i64) -> String { pub fn runtime_profile_recharge_point_products() -> Vec { vec![ - build_points_recharge_product("points_60", "60泥点", 600, 60, 0, "", "60泥点"), - build_points_recharge_product( - "points_180", - "180泥点", - 1800, - 180, - 90, - "首充加赠", - "首充加赠90泥点", - ), - build_points_recharge_product( - "points_300", - "300泥点", - 3000, - 300, - 150, - "首充加赠", - "首充加赠150泥点", - ), - build_points_recharge_product( - "points_680", - "680泥点", - 6800, - 680, - 340, - "首充加赠", - "首充加赠340泥点", - ), - ] -} - -/// 中文注释:保留旧展示 helper 的兼容入口;首充资格已改为按商品档位在配置表侧计算。 -pub fn resolve_runtime_profile_recharge_point_products( - _has_points_recharged: bool, -) -> Vec { - runtime_profile_recharge_point_products() -} - -pub fn runtime_profile_recharge_membership_products() -> Vec -{ - vec![ - build_membership_recharge_product( - "member_starter", - "Starter", - 1990, - 30, - RuntimeProfileMembershipTier::Starter, - "每月200泥点 用于游戏创作", - ), - build_membership_recharge_product( - "member_basic", - "Basic", - 6990, - 30, - RuntimeProfileMembershipTier::Basic, - "每月800泥点 用于游戏创作", - ), - build_membership_recharge_product( - "member_pro", - "Pro", - 19990, - 30, - RuntimeProfileMembershipTier::Pro, - "每月2500泥点 用于游戏创作", - ), - build_membership_recharge_product( - "member_ultimate", - "Ultimate", - 41990, - 30, - RuntimeProfileMembershipTier::Ultimate, - "每月6000泥点 用于游戏创作", - ), - ] -} - -pub fn runtime_profile_membership_tier_rank(tier: RuntimeProfileMembershipTier) -> Option { - match tier { - RuntimeProfileMembershipTier::Normal => None, - RuntimeProfileMembershipTier::Month | RuntimeProfileMembershipTier::Starter => Some(1), - RuntimeProfileMembershipTier::Season | RuntimeProfileMembershipTier::Basic => Some(2), - RuntimeProfileMembershipTier::Year | RuntimeProfileMembershipTier::Pro => Some(3), - RuntimeProfileMembershipTier::Ultimate => Some(4), - } -} - -pub fn runtime_profile_membership_period_points(tier: RuntimeProfileMembershipTier) -> u64 { - match tier { - RuntimeProfileMembershipTier::Starter => 200, - RuntimeProfileMembershipTier::Basic => 800, - RuntimeProfileMembershipTier::Pro => 2500, - RuntimeProfileMembershipTier::Ultimate => 6000, - _ => 0, - } -} - -pub fn runtime_profile_membership_queue_limit(tier: RuntimeProfileMembershipTier) -> u32 { - match tier { - RuntimeProfileMembershipTier::Starter | RuntimeProfileMembershipTier::Basic => 2, - RuntimeProfileMembershipTier::Pro => 5, - RuntimeProfileMembershipTier::Ultimate => 10, - _ => 0, - } -} - -pub fn runtime_profile_membership_discount_bps(tier: RuntimeProfileMembershipTier) -> u32 { - match tier { - RuntimeProfileMembershipTier::Basic => 8500, - RuntimeProfileMembershipTier::Pro => 8000, - RuntimeProfileMembershipTier::Ultimate => 7000, - _ => 0, - } -} - -pub fn runtime_profile_membership_benefits() -> Vec { - vec![ - RuntimeProfileMembershipBenefitSnapshot { - benefit_name: "特权名称".to_string(), - normal_value: "普通".to_string(), - month_value: "月卡".to_string(), - season_value: "季卡".to_string(), - year_value: "年卡".to_string(), - starter_value: "Starter".to_string(), - basic_value: "Basic".to_string(), - pro_value: "Pro".to_string(), - ultimate_value: "Ultimate".to_string(), - }, - RuntimeProfileMembershipBenefitSnapshot { - benefit_name: "每月泥点".to_string(), - normal_value: "0".to_string(), - month_value: "0".to_string(), - season_value: "0".to_string(), - year_value: "0".to_string(), - starter_value: "200".to_string(), - basic_value: "800".to_string(), - pro_value: "2500".to_string(), - ultimate_value: "6000".to_string(), - }, - RuntimeProfileMembershipBenefitSnapshot { - benefit_name: "同时排队".to_string(), - normal_value: "1".to_string(), - month_value: "2".to_string(), - season_value: "2".to_string(), - year_value: "2".to_string(), - starter_value: "2".to_string(), - basic_value: "2".to_string(), - pro_value: "5".to_string(), - ultimate_value: "10".to_string(), - }, - RuntimeProfileMembershipBenefitSnapshot { - benefit_name: "商业所有权".to_string(), - normal_value: "0%".to_string(), - month_value: "有".to_string(), - season_value: "有".to_string(), - year_value: "有".to_string(), - starter_value: "有".to_string(), - basic_value: "有".to_string(), - pro_value: "有".to_string(), - ultimate_value: "有".to_string(), - }, + build_points_recharge_product("points_60", "60泥点", 600, 60), + build_points_recharge_product("points_180", "180泥点", 1800, 180), + build_points_recharge_product("points_300", "300泥点", 3000, 300), + build_points_recharge_product("points_680", "680泥点", 6800, 680), + build_points_recharge_product("points_1280", "1280泥点", 12800, 1280), + build_points_recharge_product("points_3280", "3280泥点", 32800, 3280), ] } @@ -199,7 +43,6 @@ pub fn runtime_profile_recharge_product_by_id( ) -> Option { runtime_profile_recharge_point_products() .into_iter() - .chain(runtime_profile_recharge_membership_products()) .find(|product| product.product_id == product_id) } @@ -215,9 +58,6 @@ fn build_points_recharge_product( title: &str, price_cents: u64, points_amount: u64, - bonus_points: u64, - badge_label: &str, - description: &str, ) -> RuntimeProfileRechargeProductSnapshot { RuntimeProfileRechargeProductSnapshot { product_id: product_id.to_string(), @@ -225,41 +65,10 @@ fn build_points_recharge_product( price_cents, kind: RuntimeProfileRechargeProductKind::Points, points_amount, - bonus_points, - duration_days: 0, - badge_label: badge_label.to_string(), - description: description.to_string(), - tier: RuntimeProfileMembershipTier::Normal, - membership_period_points: 0, - membership_period_days: 0, - membership_queue_limit: 0, - membership_discount_bps: 0, - } -} - -fn build_membership_recharge_product( - product_id: &str, - title: &str, - price_cents: u64, - duration_days: u32, - tier: RuntimeProfileMembershipTier, - description: &str, -) -> RuntimeProfileRechargeProductSnapshot { - RuntimeProfileRechargeProductSnapshot { - product_id: product_id.to_string(), - title: title.to_string(), - price_cents, - kind: RuntimeProfileRechargeProductKind::Membership, - points_amount: 0, bonus_points: 0, - duration_days, + duration_days: 0, badge_label: String::new(), - description: description.to_string(), - tier, - membership_period_points: runtime_profile_membership_period_points(tier), - membership_period_days: PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS, - membership_queue_limit: runtime_profile_membership_queue_limit(tier), - membership_discount_bps: runtime_profile_membership_discount_bps(tier), + description: title.to_string(), } } @@ -1129,59 +938,46 @@ mod tests { #[test] fn recharge_product_catalog_matches_reference_prices() { let point_products = runtime_profile_recharge_point_products(); - let membership_products = runtime_profile_recharge_membership_products(); - assert_eq!(point_products.len(), 4); - assert_eq!(point_products[0].product_id, "points_60"); - assert_eq!(point_products[0].title, "60泥点"); - assert_eq!(point_products[0].price_cents, 600); - assert_eq!(point_products[0].bonus_points, 0); - assert_eq!(point_products[0].badge_label, ""); - assert_eq!(point_products[0].description, "60泥点"); - assert_eq!(point_products[1].product_id, "points_180"); - assert_eq!(point_products[1].bonus_points, 90); - assert_eq!(point_products[2].bonus_points, 150); - assert_eq!(point_products[3].product_id, "points_680"); - assert_eq!(point_products[3].price_cents, 6800); - assert_eq!(point_products[3].bonus_points, 340); - assert_eq!(point_products[3].description, "首充加赠340泥点"); - assert_eq!(membership_products.len(), 4); - assert_eq!(membership_products[0].product_id, "member_starter"); - assert_eq!(membership_products[0].title, "Starter"); - assert_eq!(membership_products[0].price_cents, 1990); - assert_eq!(membership_products[0].membership_period_points, 200); + assert_eq!(point_products.len(), 6); + for (product, product_id, price_cents, points_amount) in [ + (&point_products[0], "points_60", 600, 60), + (&point_products[1], "points_180", 1800, 180), + (&point_products[2], "points_300", 3000, 300), + (&point_products[3], "points_680", 6800, 680), + (&point_products[4], "points_1280", 12800, 1280), + (&point_products[5], "points_3280", 32800, 3280), + ] { + assert_eq!(product.product_id, product_id); + assert_eq!(product.price_cents, price_cents); + assert_eq!(product.points_amount, points_amount); + // 中文注释:新方案取消首充赠送,六档充点一律无赠送、无角标。 + assert_eq!(product.bonus_points, 0); + assert_eq!(product.badge_label, ""); + } + + let plans = runtime_profile_membership_plan_catalog(); + assert_eq!(plans.len(), 5); + assert_eq!(plans[0].plan, RuntimeProfileMembershipPlan::Normal); + assert!(!plans[0].enabled); + assert_eq!(plans[0].period_points, 0); + assert_eq!(plans[0].concurrent_job_limit, 1); + assert_eq!(plans[1].plan, RuntimeProfileMembershipPlan::Starter); + assert_eq!(plans[1].month_price_cents, 3_900); + assert_eq!(plans[1].year_price_cents, 39_000); + assert_eq!(plans[1].period_points, 400); + assert_eq!(plans[2].plan, RuntimeProfileMembershipPlan::Plus); + assert_eq!(plans[2].period_points, 1_150); + assert_eq!(plans[3].plan, RuntimeProfileMembershipPlan::Pro); + assert_eq!(plans[3].period_points, 3_650); + assert_eq!(plans[4].plan, RuntimeProfileMembershipPlan::Max); + assert_eq!(plans[4].month_price_cents, 69_900); + assert_eq!(plans[4].year_price_cents, 699_000); + assert_eq!(plans[4].period_points, 8_650); assert_eq!( - membership_products[0].membership_period_days, - PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS + plans[4].concurrent_job_limit, + MEMBERSHIP_UNLIMITED_CONCURRENCY ); - assert_eq!(membership_products[3].product_id, "member_ultimate"); - assert_eq!(membership_products[3].membership_period_points, 6000); - assert_eq!(membership_products[3].membership_queue_limit, 10); - - let benefits = runtime_profile_membership_benefits(); - assert!( - benefits - .iter() - .any(|benefit| benefit.benefit_name == "每月泥点") - ); - } - - #[test] - fn recharge_point_products_do_not_hide_all_first_bonus_by_account_flag() { - let first_recharge_products = resolve_runtime_profile_recharge_point_products(false); - assert_eq!(first_recharge_products[0].bonus_points, 0); - assert_eq!(first_recharge_products[0].badge_label, ""); - assert_eq!(first_recharge_products[0].description, "60泥点"); - assert_eq!(first_recharge_products[1].bonus_points, 90); - assert_eq!(first_recharge_products[1].badge_label, "首充加赠"); - assert_eq!(first_recharge_products[1].description, "首充加赠90泥点"); - - let repeated_recharge_products = resolve_runtime_profile_recharge_point_products(true); - assert_eq!(repeated_recharge_products[0].bonus_points, 0); - assert_eq!(repeated_recharge_products[1].bonus_points, 90); - assert_eq!(repeated_recharge_products[3].bonus_points, 340); - assert_eq!(repeated_recharge_products[3].badge_label, "首充加赠"); - assert_eq!(repeated_recharge_products[3].description, "首充加赠340泥点"); } #[test] From 1790ddff6aba96fe34f026a412ad27a84905b692 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 15:00:04 +0800 Subject: [PATCH 012/232] =?UTF-8?q?Revert=20"=E9=87=8D=E6=9E=84=EF=BC=9A?= =?UTF-8?q?=E6=B8=85=E9=80=80=E4=BC=9A=E5=91=98=E9=81=97=E7=95=99=E6=A1=A3?= =?UTF-8?q?=E4=BD=8D=E4=B8=8E=E5=95=86=E5=93=81=E6=9D=83=E7=9B=8A=E5=AD=97?= =?UTF-8?q?=E6=AE=B5"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 103f3b548aa59d7e8202f7b2cbb1637b16893cdb. --- .../crates/module-runtime/src/application.rs | 43 ++- .../crates/module-runtime/src/commands.rs | 31 +- server-rs/crates/module-runtime/src/domain.rs | 103 +++++- server-rs/crates/module-runtime/src/lib.rs | 294 +++++++++++++++--- 4 files changed, 404 insertions(+), 67 deletions(-) diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 4779ec1fa..19de95527 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -1052,7 +1052,16 @@ pub fn build_runtime_profile_recharge_center_record( .into_iter() .map(build_runtime_profile_recharge_product_record) .collect(), - membership_plans: snapshot.membership_plans, + membership_products: snapshot + .membership_products + .into_iter() + .map(build_runtime_profile_recharge_product_record) + .collect(), + benefits: snapshot + .benefits + .into_iter() + .map(build_runtime_profile_membership_benefit_record) + .collect(), latest_order: snapshot .latest_order .map(build_runtime_profile_recharge_order_record), @@ -1076,6 +1085,11 @@ pub fn build_runtime_profile_recharge_product_record( duration_days: snapshot.duration_days, badge_label: snapshot.badge_label, description: snapshot.description, + tier: snapshot.tier, + membership_period_points: snapshot.membership_period_points, + membership_period_days: snapshot.membership_period_days, + membership_queue_limit: snapshot.membership_queue_limit, + membership_discount_bps: snapshot.membership_discount_bps, } } @@ -1092,6 +1106,11 @@ pub fn build_runtime_profile_recharge_product_config_record( duration_days: snapshot.duration_days, badge_label: snapshot.badge_label, description: snapshot.description, + tier: snapshot.tier, + membership_period_points: snapshot.membership_period_points, + membership_period_days: snapshot.membership_period_days, + membership_queue_limit: snapshot.membership_queue_limit, + membership_discount_bps: snapshot.membership_discount_bps, enabled: snapshot.enabled, sort_order: snapshot.sort_order, created_by: snapshot.created_by, @@ -1103,13 +1122,29 @@ pub fn build_runtime_profile_recharge_product_config_record( } } +pub fn build_runtime_profile_membership_benefit_record( + snapshot: RuntimeProfileMembershipBenefitSnapshot, +) -> RuntimeProfileMembershipBenefitRecord { + RuntimeProfileMembershipBenefitRecord { + benefit_name: snapshot.benefit_name, + normal_value: snapshot.normal_value, + month_value: snapshot.month_value, + season_value: snapshot.season_value, + year_value: snapshot.year_value, + starter_value: snapshot.starter_value, + basic_value: snapshot.basic_value, + pro_value: snapshot.pro_value, + ultimate_value: snapshot.ultimate_value, + } +} + pub fn build_runtime_profile_membership_record( snapshot: RuntimeProfileMembershipSnapshot, ) -> RuntimeProfileMembershipRecord { RuntimeProfileMembershipRecord { user_id: snapshot.user_id, status: snapshot.status, - plan: snapshot.plan, + tier: snapshot.tier, started_at: snapshot.started_at_micros.map(format_utc_micros), started_at_micros: snapshot.started_at_micros, expires_at: snapshot.expires_at_micros.map(format_utc_micros), @@ -1122,9 +1157,7 @@ pub fn build_runtime_profile_membership_record( cycle_resets_at_micros: snapshot.cycle_resets_at_micros, cycle_granted_points: snapshot.cycle_granted_points, cycle_remaining_points: snapshot.cycle_remaining_points, - cycle_index: snapshot.cycle_index, - cycle_count: snapshot.cycle_count, - cycle_kind: snapshot.cycle_kind, + cycle_period_days: snapshot.cycle_period_days, } } diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index bab1dff70..138bb2cf3 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -392,6 +392,11 @@ pub fn build_runtime_profile_recharge_product_admin_upsert_input( duration_days: u32, badge_label: String, description: String, + tier: RuntimeProfileMembershipTier, + membership_period_points: u64, + membership_period_days: u32, + membership_queue_limit: u32, + membership_discount_bps: u32, enabled: bool, sort_order: i32, updated_at_micros: i64, @@ -409,13 +414,28 @@ pub fn build_runtime_profile_recharge_product_admin_upsert_input( if points_amount == 0 { return Err(RuntimeProfileFieldError::InvalidRechargeProductPoints); } - if duration_days != 0 || bonus_points != 0 { + if duration_days != 0 + || tier != RuntimeProfileMembershipTier::Normal + || membership_period_points != 0 + || membership_period_days != 0 + || membership_queue_limit != 0 + || membership_discount_bps != 0 + { return Err(RuntimeProfileFieldError::InvalidRechargeProductTier); } } - // 中文注释:会员商品已迁移到 profile_membership_plan 目录,这里只保留泥点商品。 RuntimeProfileRechargeProductKind::Membership => { - return Err(RuntimeProfileFieldError::InvalidRechargeProductTier); + if duration_days == 0 { + return Err(RuntimeProfileFieldError::InvalidRechargeProductDuration); + } + if points_amount != 0 + || bonus_points != 0 + || tier == RuntimeProfileMembershipTier::Normal + || membership_period_points == 0 + || membership_period_days == 0 + { + return Err(RuntimeProfileFieldError::InvalidRechargeProductTier); + } } } @@ -430,6 +450,11 @@ pub fn build_runtime_profile_recharge_product_admin_upsert_input( duration_days, badge_label: normalize_optional_string(Some(badge_label)).unwrap_or_default(), description: normalize_optional_string(Some(description)).unwrap_or_default(), + tier, + membership_period_points, + membership_period_days, + membership_queue_limit, + membership_discount_bps, enabled, sort_order, updated_at_micros, diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 267ff368e..be3856223 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -4,8 +4,6 @@ //! 纯领域事实与值对象;SpacetimeDB client 与 HTTP response 不进入本文件。 use serde::{Deserialize, Serialize}; - -use crate::RuntimeProfileMembershipPlanSnapshot; #[cfg(any())] use serde_json::Value; @@ -1231,10 +1229,37 @@ impl RuntimeProfileMembershipStatus { } } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileMembershipTier { + Normal, + Month, + Season, + Year, + Starter, + Basic, + Pro, + Ultimate, +} + +impl RuntimeProfileMembershipTier { + pub fn as_str(&self) -> &'static str { + match self { + Self::Normal => "normal", + Self::Month => "month", + Self::Season => "season", + Self::Year => "year", + Self::Starter => "starter", + Self::Basic => "basic", + Self::Pro => "pro", + Self::Ultimate => "ultimate", + } + } +} + /// 会员档位身份。取代旧的 `RuntimeProfileMembershipTier`,作为唯一权威档位标识。 /// -/// 变体顺序即排序基准(`Normal < Starter < Plus < Pro < Max`)。对外 wire 只用 -/// [`RuntimeProfileMembershipPlan::as_str`] 产出的枚举 token。 +/// 变体顺序即排序基准(`Normal < Starter < Plus < Pro < Max`),升级比较不再依赖旧枚举别名。 #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimeProfileMembershipPlan { @@ -1489,6 +1514,11 @@ pub struct RuntimeProfileRechargeProductSnapshot { pub duration_days: u32, pub badge_label: String, pub description: String, + pub tier: RuntimeProfileMembershipTier, + pub membership_period_points: u64, + pub membership_period_days: u32, + pub membership_queue_limit: u32, + pub membership_discount_bps: u32, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -1503,6 +1533,11 @@ pub struct RuntimeProfileRechargeProductConfigSnapshot { pub duration_days: u32, pub badge_label: String, pub description: String, + pub tier: RuntimeProfileMembershipTier, + pub membership_period_points: u64, + pub membership_period_days: u32, + pub membership_queue_limit: u32, + pub membership_discount_bps: u32, pub enabled: bool, pub sort_order: i32, pub created_by: String, @@ -1511,12 +1546,26 @@ pub struct RuntimeProfileRechargeProductConfigSnapshot { pub updated_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipBenefitSnapshot { + pub benefit_name: String, + pub normal_value: String, + pub month_value: String, + pub season_value: String, + pub year_value: String, + pub starter_value: String, + pub basic_value: String, + pub pro_value: String, + pub ultimate_value: String, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileMembershipSnapshot { pub user_id: String, pub status: RuntimeProfileMembershipStatus, - pub plan: RuntimeProfileMembershipPlan, + pub tier: RuntimeProfileMembershipTier, pub started_at_micros: Option, pub expires_at_micros: Option, pub updated_at_micros: Option, @@ -1524,9 +1573,7 @@ pub struct RuntimeProfileMembershipSnapshot { pub cycle_resets_at_micros: Option, pub cycle_granted_points: u64, pub cycle_remaining_points: u64, - pub cycle_index: u32, - pub cycle_count: u32, - pub cycle_kind: RuntimeProfileMembershipCycleKind, + pub cycle_period_days: u32, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -1718,7 +1765,8 @@ pub struct RuntimeProfileRechargeCenterSnapshot { pub wallet_balance: u64, pub membership: RuntimeProfileMembershipSnapshot, pub point_products: Vec, - pub membership_plans: Vec, + pub membership_products: Vec, + pub benefits: Vec, pub latest_order: Option, pub has_points_recharged: bool, pub daily_free_points: RuntimeProfileDailyFreePointsSnapshot, @@ -1759,6 +1807,11 @@ pub struct RuntimeProfileRechargeProductAdminUpsertInput { pub duration_days: u32, pub badge_label: String, pub description: String, + pub tier: RuntimeProfileMembershipTier, + pub membership_period_points: u64, + pub membership_period_days: u32, + pub membership_queue_limit: u32, + pub membership_discount_bps: u32, pub enabled: bool, pub sort_order: i32, pub updated_at_micros: i64, @@ -2575,6 +2628,11 @@ pub struct RuntimeProfileRechargeProductRecord { pub duration_days: u32, pub badge_label: String, pub description: String, + pub tier: RuntimeProfileMembershipTier, + pub membership_period_points: u64, + pub membership_period_days: u32, + pub membership_queue_limit: u32, + pub membership_discount_bps: u32, } #[derive(Clone, Debug, PartialEq)] @@ -2588,6 +2646,11 @@ pub struct RuntimeProfileRechargeProductConfigRecord { pub duration_days: u32, pub badge_label: String, pub description: String, + pub tier: RuntimeProfileMembershipTier, + pub membership_period_points: u64, + pub membership_period_days: u32, + pub membership_queue_limit: u32, + pub membership_discount_bps: u32, pub enabled: bool, pub sort_order: i32, pub created_by: String, @@ -2611,11 +2674,24 @@ pub struct RuntimeProfileWalletConfigRecord { pub daily_free_points_per_day: u64, } +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeProfileMembershipBenefitRecord { + pub benefit_name: String, + pub normal_value: String, + pub month_value: String, + pub season_value: String, + pub year_value: String, + pub starter_value: String, + pub basic_value: String, + pub pro_value: String, + pub ultimate_value: String, +} + #[derive(Clone, Debug, PartialEq)] pub struct RuntimeProfileMembershipRecord { pub user_id: String, pub status: RuntimeProfileMembershipStatus, - pub plan: RuntimeProfileMembershipPlan, + pub tier: RuntimeProfileMembershipTier, pub started_at: Option, pub started_at_micros: Option, pub expires_at: Option, @@ -2628,9 +2704,7 @@ pub struct RuntimeProfileMembershipRecord { pub cycle_resets_at_micros: Option, pub cycle_granted_points: u64, pub cycle_remaining_points: u64, - pub cycle_index: u32, - pub cycle_count: u32, - pub cycle_kind: RuntimeProfileMembershipCycleKind, + pub cycle_period_days: u32, } #[derive(Clone, Debug, PartialEq)] @@ -2665,7 +2739,8 @@ pub struct RuntimeProfileRechargeCenterRecord { pub wallet_balance: u64, pub membership: RuntimeProfileMembershipRecord, pub point_products: Vec, - pub membership_plans: Vec, + pub membership_products: Vec, + pub benefits: Vec, pub latest_order: Option, pub has_points_recharged: bool, pub daily_free_points: RuntimeProfileDailyFreePointsRecord, diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index ce5c04494..8c0ddb80a 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -21,6 +21,8 @@ pub use membership::*; use shared_kernel::format_rfc3339 as format_shared_rfc3339; use time::OffsetDateTime; +pub const PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS: u32 = 30; + pub fn format_utc_micros(micros: i64) -> String { let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(micros) * 1_000) .unwrap_or(OffsetDateTime::UNIX_EPOCH); @@ -29,12 +31,166 @@ pub fn format_utc_micros(micros: i64) -> String { pub fn runtime_profile_recharge_point_products() -> Vec { vec![ - build_points_recharge_product("points_60", "60泥点", 600, 60), - build_points_recharge_product("points_180", "180泥点", 1800, 180), - build_points_recharge_product("points_300", "300泥点", 3000, 300), - build_points_recharge_product("points_680", "680泥点", 6800, 680), - build_points_recharge_product("points_1280", "1280泥点", 12800, 1280), - build_points_recharge_product("points_3280", "3280泥点", 32800, 3280), + build_points_recharge_product("points_60", "60泥点", 600, 60, 0, "", "60泥点"), + build_points_recharge_product( + "points_180", + "180泥点", + 1800, + 180, + 90, + "首充加赠", + "首充加赠90泥点", + ), + build_points_recharge_product( + "points_300", + "300泥点", + 3000, + 300, + 150, + "首充加赠", + "首充加赠150泥点", + ), + build_points_recharge_product( + "points_680", + "680泥点", + 6800, + 680, + 340, + "首充加赠", + "首充加赠340泥点", + ), + ] +} + +/// 中文注释:保留旧展示 helper 的兼容入口;首充资格已改为按商品档位在配置表侧计算。 +pub fn resolve_runtime_profile_recharge_point_products( + _has_points_recharged: bool, +) -> Vec { + runtime_profile_recharge_point_products() +} + +pub fn runtime_profile_recharge_membership_products() -> Vec +{ + vec![ + build_membership_recharge_product( + "member_starter", + "Starter", + 1990, + 30, + RuntimeProfileMembershipTier::Starter, + "每月200泥点 用于游戏创作", + ), + build_membership_recharge_product( + "member_basic", + "Basic", + 6990, + 30, + RuntimeProfileMembershipTier::Basic, + "每月800泥点 用于游戏创作", + ), + build_membership_recharge_product( + "member_pro", + "Pro", + 19990, + 30, + RuntimeProfileMembershipTier::Pro, + "每月2500泥点 用于游戏创作", + ), + build_membership_recharge_product( + "member_ultimate", + "Ultimate", + 41990, + 30, + RuntimeProfileMembershipTier::Ultimate, + "每月6000泥点 用于游戏创作", + ), + ] +} + +pub fn runtime_profile_membership_tier_rank(tier: RuntimeProfileMembershipTier) -> Option { + match tier { + RuntimeProfileMembershipTier::Normal => None, + RuntimeProfileMembershipTier::Month | RuntimeProfileMembershipTier::Starter => Some(1), + RuntimeProfileMembershipTier::Season | RuntimeProfileMembershipTier::Basic => Some(2), + RuntimeProfileMembershipTier::Year | RuntimeProfileMembershipTier::Pro => Some(3), + RuntimeProfileMembershipTier::Ultimate => Some(4), + } +} + +pub fn runtime_profile_membership_period_points(tier: RuntimeProfileMembershipTier) -> u64 { + match tier { + RuntimeProfileMembershipTier::Starter => 200, + RuntimeProfileMembershipTier::Basic => 800, + RuntimeProfileMembershipTier::Pro => 2500, + RuntimeProfileMembershipTier::Ultimate => 6000, + _ => 0, + } +} + +pub fn runtime_profile_membership_queue_limit(tier: RuntimeProfileMembershipTier) -> u32 { + match tier { + RuntimeProfileMembershipTier::Starter | RuntimeProfileMembershipTier::Basic => 2, + RuntimeProfileMembershipTier::Pro => 5, + RuntimeProfileMembershipTier::Ultimate => 10, + _ => 0, + } +} + +pub fn runtime_profile_membership_discount_bps(tier: RuntimeProfileMembershipTier) -> u32 { + match tier { + RuntimeProfileMembershipTier::Basic => 8500, + RuntimeProfileMembershipTier::Pro => 8000, + RuntimeProfileMembershipTier::Ultimate => 7000, + _ => 0, + } +} + +pub fn runtime_profile_membership_benefits() -> Vec { + vec![ + RuntimeProfileMembershipBenefitSnapshot { + benefit_name: "特权名称".to_string(), + normal_value: "普通".to_string(), + month_value: "月卡".to_string(), + season_value: "季卡".to_string(), + year_value: "年卡".to_string(), + starter_value: "Starter".to_string(), + basic_value: "Basic".to_string(), + pro_value: "Pro".to_string(), + ultimate_value: "Ultimate".to_string(), + }, + RuntimeProfileMembershipBenefitSnapshot { + benefit_name: "每月泥点".to_string(), + normal_value: "0".to_string(), + month_value: "0".to_string(), + season_value: "0".to_string(), + year_value: "0".to_string(), + starter_value: "200".to_string(), + basic_value: "800".to_string(), + pro_value: "2500".to_string(), + ultimate_value: "6000".to_string(), + }, + RuntimeProfileMembershipBenefitSnapshot { + benefit_name: "同时排队".to_string(), + normal_value: "1".to_string(), + month_value: "2".to_string(), + season_value: "2".to_string(), + year_value: "2".to_string(), + starter_value: "2".to_string(), + basic_value: "2".to_string(), + pro_value: "5".to_string(), + ultimate_value: "10".to_string(), + }, + RuntimeProfileMembershipBenefitSnapshot { + benefit_name: "商业所有权".to_string(), + normal_value: "0%".to_string(), + month_value: "有".to_string(), + season_value: "有".to_string(), + year_value: "有".to_string(), + starter_value: "有".to_string(), + basic_value: "有".to_string(), + pro_value: "有".to_string(), + ultimate_value: "有".to_string(), + }, ] } @@ -43,6 +199,7 @@ pub fn runtime_profile_recharge_product_by_id( ) -> Option { runtime_profile_recharge_point_products() .into_iter() + .chain(runtime_profile_recharge_membership_products()) .find(|product| product.product_id == product_id) } @@ -58,6 +215,9 @@ fn build_points_recharge_product( title: &str, price_cents: u64, points_amount: u64, + bonus_points: u64, + badge_label: &str, + description: &str, ) -> RuntimeProfileRechargeProductSnapshot { RuntimeProfileRechargeProductSnapshot { product_id: product_id.to_string(), @@ -65,10 +225,41 @@ fn build_points_recharge_product( price_cents, kind: RuntimeProfileRechargeProductKind::Points, points_amount, - bonus_points: 0, + bonus_points, duration_days: 0, + badge_label: badge_label.to_string(), + description: description.to_string(), + tier: RuntimeProfileMembershipTier::Normal, + membership_period_points: 0, + membership_period_days: 0, + membership_queue_limit: 0, + membership_discount_bps: 0, + } +} + +fn build_membership_recharge_product( + product_id: &str, + title: &str, + price_cents: u64, + duration_days: u32, + tier: RuntimeProfileMembershipTier, + description: &str, +) -> RuntimeProfileRechargeProductSnapshot { + RuntimeProfileRechargeProductSnapshot { + product_id: product_id.to_string(), + title: title.to_string(), + price_cents, + kind: RuntimeProfileRechargeProductKind::Membership, + points_amount: 0, + bonus_points: 0, + duration_days, badge_label: String::new(), - description: title.to_string(), + description: description.to_string(), + tier, + membership_period_points: runtime_profile_membership_period_points(tier), + membership_period_days: PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS, + membership_queue_limit: runtime_profile_membership_queue_limit(tier), + membership_discount_bps: runtime_profile_membership_discount_bps(tier), } } @@ -938,46 +1129,59 @@ mod tests { #[test] fn recharge_product_catalog_matches_reference_prices() { let point_products = runtime_profile_recharge_point_products(); + let membership_products = runtime_profile_recharge_membership_products(); - assert_eq!(point_products.len(), 6); - for (product, product_id, price_cents, points_amount) in [ - (&point_products[0], "points_60", 600, 60), - (&point_products[1], "points_180", 1800, 180), - (&point_products[2], "points_300", 3000, 300), - (&point_products[3], "points_680", 6800, 680), - (&point_products[4], "points_1280", 12800, 1280), - (&point_products[5], "points_3280", 32800, 3280), - ] { - assert_eq!(product.product_id, product_id); - assert_eq!(product.price_cents, price_cents); - assert_eq!(product.points_amount, points_amount); - // 中文注释:新方案取消首充赠送,六档充点一律无赠送、无角标。 - assert_eq!(product.bonus_points, 0); - assert_eq!(product.badge_label, ""); - } - - let plans = runtime_profile_membership_plan_catalog(); - assert_eq!(plans.len(), 5); - assert_eq!(plans[0].plan, RuntimeProfileMembershipPlan::Normal); - assert!(!plans[0].enabled); - assert_eq!(plans[0].period_points, 0); - assert_eq!(plans[0].concurrent_job_limit, 1); - assert_eq!(plans[1].plan, RuntimeProfileMembershipPlan::Starter); - assert_eq!(plans[1].month_price_cents, 3_900); - assert_eq!(plans[1].year_price_cents, 39_000); - assert_eq!(plans[1].period_points, 400); - assert_eq!(plans[2].plan, RuntimeProfileMembershipPlan::Plus); - assert_eq!(plans[2].period_points, 1_150); - assert_eq!(plans[3].plan, RuntimeProfileMembershipPlan::Pro); - assert_eq!(plans[3].period_points, 3_650); - assert_eq!(plans[4].plan, RuntimeProfileMembershipPlan::Max); - assert_eq!(plans[4].month_price_cents, 69_900); - assert_eq!(plans[4].year_price_cents, 699_000); - assert_eq!(plans[4].period_points, 8_650); + assert_eq!(point_products.len(), 4); + assert_eq!(point_products[0].product_id, "points_60"); + assert_eq!(point_products[0].title, "60泥点"); + assert_eq!(point_products[0].price_cents, 600); + assert_eq!(point_products[0].bonus_points, 0); + assert_eq!(point_products[0].badge_label, ""); + assert_eq!(point_products[0].description, "60泥点"); + assert_eq!(point_products[1].product_id, "points_180"); + assert_eq!(point_products[1].bonus_points, 90); + assert_eq!(point_products[2].bonus_points, 150); + assert_eq!(point_products[3].product_id, "points_680"); + assert_eq!(point_products[3].price_cents, 6800); + assert_eq!(point_products[3].bonus_points, 340); + assert_eq!(point_products[3].description, "首充加赠340泥点"); + assert_eq!(membership_products.len(), 4); + assert_eq!(membership_products[0].product_id, "member_starter"); + assert_eq!(membership_products[0].title, "Starter"); + assert_eq!(membership_products[0].price_cents, 1990); + assert_eq!(membership_products[0].membership_period_points, 200); assert_eq!( - plans[4].concurrent_job_limit, - MEMBERSHIP_UNLIMITED_CONCURRENCY + membership_products[0].membership_period_days, + PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS ); + assert_eq!(membership_products[3].product_id, "member_ultimate"); + assert_eq!(membership_products[3].membership_period_points, 6000); + assert_eq!(membership_products[3].membership_queue_limit, 10); + + let benefits = runtime_profile_membership_benefits(); + assert!( + benefits + .iter() + .any(|benefit| benefit.benefit_name == "每月泥点") + ); + } + + #[test] + fn recharge_point_products_do_not_hide_all_first_bonus_by_account_flag() { + let first_recharge_products = resolve_runtime_profile_recharge_point_products(false); + assert_eq!(first_recharge_products[0].bonus_points, 0); + assert_eq!(first_recharge_products[0].badge_label, ""); + assert_eq!(first_recharge_products[0].description, "60泥点"); + assert_eq!(first_recharge_products[1].bonus_points, 90); + assert_eq!(first_recharge_products[1].badge_label, "首充加赠"); + assert_eq!(first_recharge_products[1].description, "首充加赠90泥点"); + + let repeated_recharge_products = resolve_runtime_profile_recharge_point_products(true); + assert_eq!(repeated_recharge_products[0].bonus_points, 0); + assert_eq!(repeated_recharge_products[1].bonus_points, 90); + assert_eq!(repeated_recharge_products[3].bonus_points, 340); + assert_eq!(repeated_recharge_products[3].badge_label, "首充加赠"); + assert_eq!(repeated_recharge_products[3].description, "首充加赠340泥点"); } #[test] From 71c778cceef3533afe7d4c55e38c22e353177634 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 15:02:15 +0800 Subject: [PATCH 013/232] =?UTF-8?q?feat(=E4=BC=9A=E5=91=98):=20=E4=BC=9A?= =?UTF-8?q?=E5=91=98=E6=A1=A3=E4=BD=8D=E7=9B=AE=E5=BD=95=E9=9A=8F=E5=85=85?= =?UTF-8?q?=E5=80=BC=E5=95=86=E5=93=81=E9=85=8D=E7=BD=AE=E6=92=AD=E7=A7=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ensure_default_profile_membership_plan,表为空时按会员目录种子整表播种。 - 播种入口挂在 ensure_default_profile_recharge_product_config,表非空时保留后台改价。 - 设计文档 §10 改为「已确认输入 + 实施状态」,记录三项拍板结论。 - 设计文档补充实施约束:module-runtime 与 spacetime-module 相互依赖,领域层清退必须与 schema 变更同批提交。 - 设计文档列出待落地清单(会员遗留清退、三池 DTO、报价接口、充点 6 档、前端同步)。 --- ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 33 ++++++++++++++++--- .../src/runtime/active/profile.rs | 30 +++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index 00e7ca41f..50bb79dd0 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -442,8 +442,33 @@ flowchart TD | 14 | 下单接口 | 复用 `POST /api/profile/recharge/orders` | | 15 | `walletBalance` | 用户面不暴露 | -## 10. 仍需你确认的输入 +## 10. 评审输入与实施清单 -- **并发与模型权限本期不执行**:目录字段与投影照常暴露,但服务端拦截留到后续里程碑——如需本期就拦,请明确。 -- **`profile_recharge_product_config` 原地删列**:若 SpacetimeDB 本地发布拒绝,回退为「保留列 + 默认值 + 不读」,是否接受该回退。 -- **旧副本 `runtime/profile.rs`**:确认删除。 +### 10.1 已确认输入(本轮拍板) + +- **并发与模型权限**:只落字段、只用于展示,服务端本期不做拦截。 +- **`profile_recharge_product_config` 原地删列**:若 SpacetimeDB 本地发布拒绝删列,接受回退为「保留列 + 写死默认值 + 运行时不读」,并在生成绑定与表目录里标注为废弃残留。 +- **旧副本 `server-rs/crates/spacetime-module/src/runtime/profile.rs`**:确认删除(已删除)。 +- **档位命名**:列名与 wire 字段统一为 `plan`,不再使用 `plan_id` / `tier`。 + +### 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`。 + +待落地——**第 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`。 + +> 约束来源:`scripts/check-spacetime-schema-guard.mjs` 只做源码静态比对,破坏性变更必须显式带 `SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1` 才会放行;而 `module-runtime` 与 `spacetime-module` 相互依赖,任一侧单独清退都会让另一侧无法编译,所以领域层清退必须与 schema 变更同批提交。 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 03b8b388a..03e973fe9 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -8436,7 +8436,37 @@ fn ensure_default_profile_task_config(ctx: &ReducerContext) -> ProfileTaskConfig }) } +/// 中文注释:会员档位目录以代码内目录为种子真相源,表为空时整表播种一次;后台改价后不再覆盖。 +fn ensure_default_profile_membership_plan(ctx: &ReducerContext) { + if ctx.db.profile_membership_plan().count() > 0 { + return; + } + + let now = ctx.timestamp; + for row in runtime_profile_membership_plan_catalog() { + ctx.db + .profile_membership_plan() + .insert(ProfileMembershipPlan { + plan: row.plan, + title: row.title, + rank: row.rank, + month_price_cents: row.month_price_cents, + year_price_cents: row.year_price_cents, + period_points: row.period_points, + model_access: row.model_access, + concurrent_job_limit: row.concurrent_job_limit, + sort_order: row.sort_order, + enabled: row.enabled, + created_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(), + created_at: now, + updated_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(), + updated_at: now, + }); + } +} + fn ensure_default_profile_recharge_product_config(ctx: &ReducerContext) { + ensure_default_profile_membership_plan(ctx); if ctx.db.profile_recharge_product_config().count() > 0 { migrate_legacy_default_profile_recharge_product_config(ctx); return; From 01c7cc8b66d061914a5e986b7a76e40b9098d117 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 15:38:52 +0800 Subject: [PATCH 014/232] =?UTF-8?q?=E9=87=8D=E6=9E=84(=E4=BC=9A=E5=91=98):?= =?UTF-8?q?=20=E6=A1=A3=E4=BD=8D=E8=BA=AB=E4=BB=BD=E6=94=B9=E4=B8=BA=20pla?= =?UTF-8?q?n=20=E6=9E=9A=E4=B8=BE=EF=BC=8C=E4=BB=B7=E6=A0=BC=E4=BB=A5?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E8=A1=A8=E4=B8=BA=E7=9C=9F=E7=9B=B8=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 领域层清理:删除 RuntimeProfileMembershipTier 及旧的 tier 别名体系 - 会员目录行改名 RuntimeProfileMembershipPlanRecord,快照新增 enabled 字段 - 补差升级报价入参改为「目录表解析出的当前/目标套餐快照」 - 新增会员商品 product_id 编解码(档位 + 周期,严格解析拒绝未知 token) - 报价与允许操作矩阵改为读取快照 rank / enabled,不再走编译期常量 Co-authored-by: Junie --- .../crates/module-runtime/src/application.rs | 43 +-- .../crates/module-runtime/src/commands.rs | 31 +- server-rs/crates/module-runtime/src/domain.rs | 103 +----- server-rs/crates/module-runtime/src/lib.rs | 294 +++--------------- .../module-runtime/src/membership/catalog.rs | 153 +++++++-- .../module-runtime/src/membership/upgrade.rs | 146 +++++---- 6 files changed, 286 insertions(+), 484 deletions(-) diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 19de95527..4779ec1fa 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -1052,16 +1052,7 @@ pub fn build_runtime_profile_recharge_center_record( .into_iter() .map(build_runtime_profile_recharge_product_record) .collect(), - membership_products: snapshot - .membership_products - .into_iter() - .map(build_runtime_profile_recharge_product_record) - .collect(), - benefits: snapshot - .benefits - .into_iter() - .map(build_runtime_profile_membership_benefit_record) - .collect(), + membership_plans: snapshot.membership_plans, latest_order: snapshot .latest_order .map(build_runtime_profile_recharge_order_record), @@ -1085,11 +1076,6 @@ pub fn build_runtime_profile_recharge_product_record( duration_days: snapshot.duration_days, badge_label: snapshot.badge_label, description: snapshot.description, - tier: snapshot.tier, - membership_period_points: snapshot.membership_period_points, - membership_period_days: snapshot.membership_period_days, - membership_queue_limit: snapshot.membership_queue_limit, - membership_discount_bps: snapshot.membership_discount_bps, } } @@ -1106,11 +1092,6 @@ pub fn build_runtime_profile_recharge_product_config_record( duration_days: snapshot.duration_days, badge_label: snapshot.badge_label, description: snapshot.description, - tier: snapshot.tier, - membership_period_points: snapshot.membership_period_points, - membership_period_days: snapshot.membership_period_days, - membership_queue_limit: snapshot.membership_queue_limit, - membership_discount_bps: snapshot.membership_discount_bps, enabled: snapshot.enabled, sort_order: snapshot.sort_order, created_by: snapshot.created_by, @@ -1122,29 +1103,13 @@ pub fn build_runtime_profile_recharge_product_config_record( } } -pub fn build_runtime_profile_membership_benefit_record( - snapshot: RuntimeProfileMembershipBenefitSnapshot, -) -> RuntimeProfileMembershipBenefitRecord { - RuntimeProfileMembershipBenefitRecord { - benefit_name: snapshot.benefit_name, - normal_value: snapshot.normal_value, - month_value: snapshot.month_value, - season_value: snapshot.season_value, - year_value: snapshot.year_value, - starter_value: snapshot.starter_value, - basic_value: snapshot.basic_value, - pro_value: snapshot.pro_value, - ultimate_value: snapshot.ultimate_value, - } -} - pub fn build_runtime_profile_membership_record( snapshot: RuntimeProfileMembershipSnapshot, ) -> RuntimeProfileMembershipRecord { RuntimeProfileMembershipRecord { user_id: snapshot.user_id, status: snapshot.status, - tier: snapshot.tier, + plan: snapshot.plan, started_at: snapshot.started_at_micros.map(format_utc_micros), started_at_micros: snapshot.started_at_micros, expires_at: snapshot.expires_at_micros.map(format_utc_micros), @@ -1157,7 +1122,9 @@ pub fn build_runtime_profile_membership_record( cycle_resets_at_micros: snapshot.cycle_resets_at_micros, cycle_granted_points: snapshot.cycle_granted_points, cycle_remaining_points: snapshot.cycle_remaining_points, - cycle_period_days: snapshot.cycle_period_days, + cycle_index: snapshot.cycle_index, + cycle_count: snapshot.cycle_count, + cycle_kind: snapshot.cycle_kind, } } diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 138bb2cf3..bab1dff70 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -392,11 +392,6 @@ pub fn build_runtime_profile_recharge_product_admin_upsert_input( duration_days: u32, badge_label: String, description: String, - tier: RuntimeProfileMembershipTier, - membership_period_points: u64, - membership_period_days: u32, - membership_queue_limit: u32, - membership_discount_bps: u32, enabled: bool, sort_order: i32, updated_at_micros: i64, @@ -414,28 +409,13 @@ pub fn build_runtime_profile_recharge_product_admin_upsert_input( if points_amount == 0 { return Err(RuntimeProfileFieldError::InvalidRechargeProductPoints); } - if duration_days != 0 - || tier != RuntimeProfileMembershipTier::Normal - || membership_period_points != 0 - || membership_period_days != 0 - || membership_queue_limit != 0 - || membership_discount_bps != 0 - { + if duration_days != 0 || bonus_points != 0 { return Err(RuntimeProfileFieldError::InvalidRechargeProductTier); } } + // 中文注释:会员商品已迁移到 profile_membership_plan 目录,这里只保留泥点商品。 RuntimeProfileRechargeProductKind::Membership => { - if duration_days == 0 { - return Err(RuntimeProfileFieldError::InvalidRechargeProductDuration); - } - if points_amount != 0 - || bonus_points != 0 - || tier == RuntimeProfileMembershipTier::Normal - || membership_period_points == 0 - || membership_period_days == 0 - { - return Err(RuntimeProfileFieldError::InvalidRechargeProductTier); - } + return Err(RuntimeProfileFieldError::InvalidRechargeProductTier); } } @@ -450,11 +430,6 @@ pub fn build_runtime_profile_recharge_product_admin_upsert_input( duration_days, badge_label: normalize_optional_string(Some(badge_label)).unwrap_or_default(), description: normalize_optional_string(Some(description)).unwrap_or_default(), - tier, - membership_period_points, - membership_period_days, - membership_queue_limit, - membership_discount_bps, enabled, sort_order, updated_at_micros, diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index be3856223..267ff368e 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -4,6 +4,8 @@ //! 纯领域事实与值对象;SpacetimeDB client 与 HTTP response 不进入本文件。 use serde::{Deserialize, Serialize}; + +use crate::RuntimeProfileMembershipPlanSnapshot; #[cfg(any())] use serde_json::Value; @@ -1229,37 +1231,10 @@ impl RuntimeProfileMembershipStatus { } } -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum RuntimeProfileMembershipTier { - Normal, - Month, - Season, - Year, - Starter, - Basic, - Pro, - Ultimate, -} - -impl RuntimeProfileMembershipTier { - pub fn as_str(&self) -> &'static str { - match self { - Self::Normal => "normal", - Self::Month => "month", - Self::Season => "season", - Self::Year => "year", - Self::Starter => "starter", - Self::Basic => "basic", - Self::Pro => "pro", - Self::Ultimate => "ultimate", - } - } -} - /// 会员档位身份。取代旧的 `RuntimeProfileMembershipTier`,作为唯一权威档位标识。 /// -/// 变体顺序即排序基准(`Normal < Starter < Plus < Pro < Max`),升级比较不再依赖旧枚举别名。 +/// 变体顺序即排序基准(`Normal < Starter < Plus < Pro < Max`)。对外 wire 只用 +/// [`RuntimeProfileMembershipPlan::as_str`] 产出的枚举 token。 #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimeProfileMembershipPlan { @@ -1514,11 +1489,6 @@ pub struct RuntimeProfileRechargeProductSnapshot { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: RuntimeProfileMembershipTier, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -1533,11 +1503,6 @@ pub struct RuntimeProfileRechargeProductConfigSnapshot { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: RuntimeProfileMembershipTier, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, pub enabled: bool, pub sort_order: i32, pub created_by: String, @@ -1546,26 +1511,12 @@ pub struct RuntimeProfileRechargeProductConfigSnapshot { pub updated_at_micros: i64, } -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct RuntimeProfileMembershipBenefitSnapshot { - pub benefit_name: String, - pub normal_value: String, - pub month_value: String, - pub season_value: String, - pub year_value: String, - pub starter_value: String, - pub basic_value: String, - pub pro_value: String, - pub ultimate_value: String, -} - #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileMembershipSnapshot { pub user_id: String, pub status: RuntimeProfileMembershipStatus, - pub tier: RuntimeProfileMembershipTier, + pub plan: RuntimeProfileMembershipPlan, pub started_at_micros: Option, pub expires_at_micros: Option, pub updated_at_micros: Option, @@ -1573,7 +1524,9 @@ pub struct RuntimeProfileMembershipSnapshot { pub cycle_resets_at_micros: Option, pub cycle_granted_points: u64, pub cycle_remaining_points: u64, - pub cycle_period_days: u32, + pub cycle_index: u32, + pub cycle_count: u32, + pub cycle_kind: RuntimeProfileMembershipCycleKind, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -1765,8 +1718,7 @@ pub struct RuntimeProfileRechargeCenterSnapshot { pub wallet_balance: u64, pub membership: RuntimeProfileMembershipSnapshot, pub point_products: Vec, - pub membership_products: Vec, - pub benefits: Vec, + pub membership_plans: Vec, pub latest_order: Option, pub has_points_recharged: bool, pub daily_free_points: RuntimeProfileDailyFreePointsSnapshot, @@ -1807,11 +1759,6 @@ pub struct RuntimeProfileRechargeProductAdminUpsertInput { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: RuntimeProfileMembershipTier, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, pub enabled: bool, pub sort_order: i32, pub updated_at_micros: i64, @@ -2628,11 +2575,6 @@ pub struct RuntimeProfileRechargeProductRecord { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: RuntimeProfileMembershipTier, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, } #[derive(Clone, Debug, PartialEq)] @@ -2646,11 +2588,6 @@ pub struct RuntimeProfileRechargeProductConfigRecord { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: RuntimeProfileMembershipTier, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, pub enabled: bool, pub sort_order: i32, pub created_by: String, @@ -2674,24 +2611,11 @@ pub struct RuntimeProfileWalletConfigRecord { pub daily_free_points_per_day: u64, } -#[derive(Clone, Debug, PartialEq)] -pub struct RuntimeProfileMembershipBenefitRecord { - pub benefit_name: String, - pub normal_value: String, - pub month_value: String, - pub season_value: String, - pub year_value: String, - pub starter_value: String, - pub basic_value: String, - pub pro_value: String, - pub ultimate_value: String, -} - #[derive(Clone, Debug, PartialEq)] pub struct RuntimeProfileMembershipRecord { pub user_id: String, pub status: RuntimeProfileMembershipStatus, - pub tier: RuntimeProfileMembershipTier, + pub plan: RuntimeProfileMembershipPlan, pub started_at: Option, pub started_at_micros: Option, pub expires_at: Option, @@ -2704,7 +2628,9 @@ pub struct RuntimeProfileMembershipRecord { pub cycle_resets_at_micros: Option, pub cycle_granted_points: u64, pub cycle_remaining_points: u64, - pub cycle_period_days: u32, + pub cycle_index: u32, + pub cycle_count: u32, + pub cycle_kind: RuntimeProfileMembershipCycleKind, } #[derive(Clone, Debug, PartialEq)] @@ -2739,8 +2665,7 @@ pub struct RuntimeProfileRechargeCenterRecord { pub wallet_balance: u64, pub membership: RuntimeProfileMembershipRecord, pub point_products: Vec, - pub membership_products: Vec, - pub benefits: Vec, + pub membership_plans: Vec, pub latest_order: Option, pub has_points_recharged: bool, pub daily_free_points: RuntimeProfileDailyFreePointsRecord, diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 8c0ddb80a..ce5c04494 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -21,8 +21,6 @@ pub use membership::*; use shared_kernel::format_rfc3339 as format_shared_rfc3339; use time::OffsetDateTime; -pub const PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS: u32 = 30; - pub fn format_utc_micros(micros: i64) -> String { let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(micros) * 1_000) .unwrap_or(OffsetDateTime::UNIX_EPOCH); @@ -31,166 +29,12 @@ pub fn format_utc_micros(micros: i64) -> String { pub fn runtime_profile_recharge_point_products() -> Vec { vec![ - build_points_recharge_product("points_60", "60泥点", 600, 60, 0, "", "60泥点"), - build_points_recharge_product( - "points_180", - "180泥点", - 1800, - 180, - 90, - "首充加赠", - "首充加赠90泥点", - ), - build_points_recharge_product( - "points_300", - "300泥点", - 3000, - 300, - 150, - "首充加赠", - "首充加赠150泥点", - ), - build_points_recharge_product( - "points_680", - "680泥点", - 6800, - 680, - 340, - "首充加赠", - "首充加赠340泥点", - ), - ] -} - -/// 中文注释:保留旧展示 helper 的兼容入口;首充资格已改为按商品档位在配置表侧计算。 -pub fn resolve_runtime_profile_recharge_point_products( - _has_points_recharged: bool, -) -> Vec { - runtime_profile_recharge_point_products() -} - -pub fn runtime_profile_recharge_membership_products() -> Vec -{ - vec![ - build_membership_recharge_product( - "member_starter", - "Starter", - 1990, - 30, - RuntimeProfileMembershipTier::Starter, - "每月200泥点 用于游戏创作", - ), - build_membership_recharge_product( - "member_basic", - "Basic", - 6990, - 30, - RuntimeProfileMembershipTier::Basic, - "每月800泥点 用于游戏创作", - ), - build_membership_recharge_product( - "member_pro", - "Pro", - 19990, - 30, - RuntimeProfileMembershipTier::Pro, - "每月2500泥点 用于游戏创作", - ), - build_membership_recharge_product( - "member_ultimate", - "Ultimate", - 41990, - 30, - RuntimeProfileMembershipTier::Ultimate, - "每月6000泥点 用于游戏创作", - ), - ] -} - -pub fn runtime_profile_membership_tier_rank(tier: RuntimeProfileMembershipTier) -> Option { - match tier { - RuntimeProfileMembershipTier::Normal => None, - RuntimeProfileMembershipTier::Month | RuntimeProfileMembershipTier::Starter => Some(1), - RuntimeProfileMembershipTier::Season | RuntimeProfileMembershipTier::Basic => Some(2), - RuntimeProfileMembershipTier::Year | RuntimeProfileMembershipTier::Pro => Some(3), - RuntimeProfileMembershipTier::Ultimate => Some(4), - } -} - -pub fn runtime_profile_membership_period_points(tier: RuntimeProfileMembershipTier) -> u64 { - match tier { - RuntimeProfileMembershipTier::Starter => 200, - RuntimeProfileMembershipTier::Basic => 800, - RuntimeProfileMembershipTier::Pro => 2500, - RuntimeProfileMembershipTier::Ultimate => 6000, - _ => 0, - } -} - -pub fn runtime_profile_membership_queue_limit(tier: RuntimeProfileMembershipTier) -> u32 { - match tier { - RuntimeProfileMembershipTier::Starter | RuntimeProfileMembershipTier::Basic => 2, - RuntimeProfileMembershipTier::Pro => 5, - RuntimeProfileMembershipTier::Ultimate => 10, - _ => 0, - } -} - -pub fn runtime_profile_membership_discount_bps(tier: RuntimeProfileMembershipTier) -> u32 { - match tier { - RuntimeProfileMembershipTier::Basic => 8500, - RuntimeProfileMembershipTier::Pro => 8000, - RuntimeProfileMembershipTier::Ultimate => 7000, - _ => 0, - } -} - -pub fn runtime_profile_membership_benefits() -> Vec { - vec![ - RuntimeProfileMembershipBenefitSnapshot { - benefit_name: "特权名称".to_string(), - normal_value: "普通".to_string(), - month_value: "月卡".to_string(), - season_value: "季卡".to_string(), - year_value: "年卡".to_string(), - starter_value: "Starter".to_string(), - basic_value: "Basic".to_string(), - pro_value: "Pro".to_string(), - ultimate_value: "Ultimate".to_string(), - }, - RuntimeProfileMembershipBenefitSnapshot { - benefit_name: "每月泥点".to_string(), - normal_value: "0".to_string(), - month_value: "0".to_string(), - season_value: "0".to_string(), - year_value: "0".to_string(), - starter_value: "200".to_string(), - basic_value: "800".to_string(), - pro_value: "2500".to_string(), - ultimate_value: "6000".to_string(), - }, - RuntimeProfileMembershipBenefitSnapshot { - benefit_name: "同时排队".to_string(), - normal_value: "1".to_string(), - month_value: "2".to_string(), - season_value: "2".to_string(), - year_value: "2".to_string(), - starter_value: "2".to_string(), - basic_value: "2".to_string(), - pro_value: "5".to_string(), - ultimate_value: "10".to_string(), - }, - RuntimeProfileMembershipBenefitSnapshot { - benefit_name: "商业所有权".to_string(), - normal_value: "0%".to_string(), - month_value: "有".to_string(), - season_value: "有".to_string(), - year_value: "有".to_string(), - starter_value: "有".to_string(), - basic_value: "有".to_string(), - pro_value: "有".to_string(), - ultimate_value: "有".to_string(), - }, + build_points_recharge_product("points_60", "60泥点", 600, 60), + build_points_recharge_product("points_180", "180泥点", 1800, 180), + build_points_recharge_product("points_300", "300泥点", 3000, 300), + build_points_recharge_product("points_680", "680泥点", 6800, 680), + build_points_recharge_product("points_1280", "1280泥点", 12800, 1280), + build_points_recharge_product("points_3280", "3280泥点", 32800, 3280), ] } @@ -199,7 +43,6 @@ pub fn runtime_profile_recharge_product_by_id( ) -> Option { runtime_profile_recharge_point_products() .into_iter() - .chain(runtime_profile_recharge_membership_products()) .find(|product| product.product_id == product_id) } @@ -215,9 +58,6 @@ fn build_points_recharge_product( title: &str, price_cents: u64, points_amount: u64, - bonus_points: u64, - badge_label: &str, - description: &str, ) -> RuntimeProfileRechargeProductSnapshot { RuntimeProfileRechargeProductSnapshot { product_id: product_id.to_string(), @@ -225,41 +65,10 @@ fn build_points_recharge_product( price_cents, kind: RuntimeProfileRechargeProductKind::Points, points_amount, - bonus_points, - duration_days: 0, - badge_label: badge_label.to_string(), - description: description.to_string(), - tier: RuntimeProfileMembershipTier::Normal, - membership_period_points: 0, - membership_period_days: 0, - membership_queue_limit: 0, - membership_discount_bps: 0, - } -} - -fn build_membership_recharge_product( - product_id: &str, - title: &str, - price_cents: u64, - duration_days: u32, - tier: RuntimeProfileMembershipTier, - description: &str, -) -> RuntimeProfileRechargeProductSnapshot { - RuntimeProfileRechargeProductSnapshot { - product_id: product_id.to_string(), - title: title.to_string(), - price_cents, - kind: RuntimeProfileRechargeProductKind::Membership, - points_amount: 0, bonus_points: 0, - duration_days, + duration_days: 0, badge_label: String::new(), - description: description.to_string(), - tier, - membership_period_points: runtime_profile_membership_period_points(tier), - membership_period_days: PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS, - membership_queue_limit: runtime_profile_membership_queue_limit(tier), - membership_discount_bps: runtime_profile_membership_discount_bps(tier), + description: title.to_string(), } } @@ -1129,59 +938,46 @@ mod tests { #[test] fn recharge_product_catalog_matches_reference_prices() { let point_products = runtime_profile_recharge_point_products(); - let membership_products = runtime_profile_recharge_membership_products(); - assert_eq!(point_products.len(), 4); - assert_eq!(point_products[0].product_id, "points_60"); - assert_eq!(point_products[0].title, "60泥点"); - assert_eq!(point_products[0].price_cents, 600); - assert_eq!(point_products[0].bonus_points, 0); - assert_eq!(point_products[0].badge_label, ""); - assert_eq!(point_products[0].description, "60泥点"); - assert_eq!(point_products[1].product_id, "points_180"); - assert_eq!(point_products[1].bonus_points, 90); - assert_eq!(point_products[2].bonus_points, 150); - assert_eq!(point_products[3].product_id, "points_680"); - assert_eq!(point_products[3].price_cents, 6800); - assert_eq!(point_products[3].bonus_points, 340); - assert_eq!(point_products[3].description, "首充加赠340泥点"); - assert_eq!(membership_products.len(), 4); - assert_eq!(membership_products[0].product_id, "member_starter"); - assert_eq!(membership_products[0].title, "Starter"); - assert_eq!(membership_products[0].price_cents, 1990); - assert_eq!(membership_products[0].membership_period_points, 200); + assert_eq!(point_products.len(), 6); + for (product, product_id, price_cents, points_amount) in [ + (&point_products[0], "points_60", 600, 60), + (&point_products[1], "points_180", 1800, 180), + (&point_products[2], "points_300", 3000, 300), + (&point_products[3], "points_680", 6800, 680), + (&point_products[4], "points_1280", 12800, 1280), + (&point_products[5], "points_3280", 32800, 3280), + ] { + assert_eq!(product.product_id, product_id); + assert_eq!(product.price_cents, price_cents); + assert_eq!(product.points_amount, points_amount); + // 中文注释:新方案取消首充赠送,六档充点一律无赠送、无角标。 + assert_eq!(product.bonus_points, 0); + assert_eq!(product.badge_label, ""); + } + + let plans = runtime_profile_membership_plan_catalog(); + assert_eq!(plans.len(), 5); + assert_eq!(plans[0].plan, RuntimeProfileMembershipPlan::Normal); + assert!(!plans[0].enabled); + assert_eq!(plans[0].period_points, 0); + assert_eq!(plans[0].concurrent_job_limit, 1); + assert_eq!(plans[1].plan, RuntimeProfileMembershipPlan::Starter); + assert_eq!(plans[1].month_price_cents, 3_900); + assert_eq!(plans[1].year_price_cents, 39_000); + assert_eq!(plans[1].period_points, 400); + assert_eq!(plans[2].plan, RuntimeProfileMembershipPlan::Plus); + assert_eq!(plans[2].period_points, 1_150); + assert_eq!(plans[3].plan, RuntimeProfileMembershipPlan::Pro); + assert_eq!(plans[3].period_points, 3_650); + assert_eq!(plans[4].plan, RuntimeProfileMembershipPlan::Max); + assert_eq!(plans[4].month_price_cents, 69_900); + assert_eq!(plans[4].year_price_cents, 699_000); + assert_eq!(plans[4].period_points, 8_650); assert_eq!( - membership_products[0].membership_period_days, - PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS + plans[4].concurrent_job_limit, + MEMBERSHIP_UNLIMITED_CONCURRENCY ); - assert_eq!(membership_products[3].product_id, "member_ultimate"); - assert_eq!(membership_products[3].membership_period_points, 6000); - assert_eq!(membership_products[3].membership_queue_limit, 10); - - let benefits = runtime_profile_membership_benefits(); - assert!( - benefits - .iter() - .any(|benefit| benefit.benefit_name == "每月泥点") - ); - } - - #[test] - fn recharge_point_products_do_not_hide_all_first_bonus_by_account_flag() { - let first_recharge_products = resolve_runtime_profile_recharge_point_products(false); - assert_eq!(first_recharge_products[0].bonus_points, 0); - assert_eq!(first_recharge_products[0].badge_label, ""); - assert_eq!(first_recharge_products[0].description, "60泥点"); - assert_eq!(first_recharge_products[1].bonus_points, 90); - assert_eq!(first_recharge_products[1].badge_label, "首充加赠"); - assert_eq!(first_recharge_products[1].description, "首充加赠90泥点"); - - let repeated_recharge_products = resolve_runtime_profile_recharge_point_products(true); - assert_eq!(repeated_recharge_products[0].bonus_points, 0); - assert_eq!(repeated_recharge_products[1].bonus_points, 90); - assert_eq!(repeated_recharge_products[3].bonus_points, 340); - assert_eq!(repeated_recharge_products[3].badge_label, "首充加赠"); - assert_eq!(repeated_recharge_products[3].description, "首充加赠340泥点"); } #[test] diff --git a/server-rs/crates/module-runtime/src/membership/catalog.rs b/server-rs/crates/module-runtime/src/membership/catalog.rs index 8e17f9c83..1fee74750 100644 --- a/server-rs/crates/module-runtime/src/membership/catalog.rs +++ b/server-rs/crates/module-runtime/src/membership/catalog.rs @@ -22,7 +22,7 @@ use crate::{ /// 目录一行:一个档位的可配置价格与权益。 #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct RuntimeProfileMembershipPlanCatalogRow { +pub struct RuntimeProfileMembershipPlanRecord { pub plan: RuntimeProfileMembershipPlan, pub title: String, pub rank: u8, @@ -37,7 +37,7 @@ pub struct RuntimeProfileMembershipPlanCatalogRow { /// 目录行按选定周期解析出的「可下单套餐」快照:`price_cents` 只保留所选周期的那一档价格。 #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeProfileMembershipPlanSnapshot { pub plan: RuntimeProfileMembershipPlan, pub title: String, @@ -48,6 +48,8 @@ pub struct RuntimeProfileMembershipPlanSnapshot { pub period_points: u64, pub model_access: RuntimeProfileMembershipModelAccess, pub concurrent_job_limit: u32, + /// 是否可在充值中心下单;`Normal` 与后台下架档位为 `false`。 + pub enabled: bool, } /// 会员目录种子(一行一档,按 `sort_order` 升序)。 @@ -55,12 +57,12 @@ pub struct RuntimeProfileMembershipPlanSnapshot { /// 数值来自产品原型 `充值与会员.html`,`Max` 的并发上限用哨兵 /// [`MEMBERSHIP_UNLIMITED_CONCURRENCY`](crate::MEMBERSHIP_UNLIMITED_CONCURRENCY) 表示「不设上限」。 /// `Normal` 是非会员占位档位,不可购买,因此 `enabled = false`。 -pub fn runtime_profile_membership_plan_catalog() -> Vec { +pub fn runtime_profile_membership_plan_catalog() -> Vec { use RuntimeProfileMembershipModelAccess::{Basic, Full}; use RuntimeProfileMembershipPlan::{Max, Normal, Plus, Pro, Starter}; vec![ - RuntimeProfileMembershipPlanCatalogRow { + RuntimeProfileMembershipPlanRecord { plan: Normal, title: "非会员".to_string(), rank: 0, @@ -72,7 +74,7 @@ pub fn runtime_profile_membership_plan_catalog() -> Vec Vec Vec Vec Vec Option { +) -> Option { runtime_profile_membership_plan_catalog() .into_iter() .find(|row| row.plan == plan) } +/// 目录行 → 可下单套餐快照,价格与权益来自目录表(后台可改)。 +pub fn build_runtime_profile_membership_plan_snapshot( + row: &RuntimeProfileMembershipPlanRecord, + cycle_kind: RuntimeProfileMembershipCycleKind, +) -> RuntimeProfileMembershipPlanSnapshot { + let price_cents = match cycle_kind { + RuntimeProfileMembershipCycleKind::Monthly => row.month_price_cents, + RuntimeProfileMembershipCycleKind::Yearly => row.year_price_cents, + }; + RuntimeProfileMembershipPlanSnapshot { + plan: row.plan, + title: row.title.clone(), + 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, + enabled: row.enabled && row.plan.is_purchasable(), + } +} + /// 档位 + 周期 → 可下单套餐快照;金额按所选周期的价格字段取值。 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, - }) + Some(build_runtime_profile_membership_plan_snapshot( + &row, cycle_kind, + )) } /// 升级比较基准:目录里的 `rank`(`Normal = 0`,越大越高)。 @@ -169,12 +182,47 @@ pub fn is_runtime_profile_membership_plan_purchasable(plan: RuntimeProfileMember .unwrap_or(false) } +/// 会员商品的 `product_id` 前缀。 +/// +/// 会员购买 / 升级复用充值订单链路,因此订单里的 `product_id` 必须能唯一还原 +/// 「档位 + 周期」;`plan` 与 `cycle_kind` 的 token 都不含 `-`,所以拼接无歧义。 +pub const MEMBERSHIP_PRODUCT_ID_PREFIX: &str = "membership"; + +/// 档位 + 周期 → 会员商品 `product_id`(例如 `membership-pro-yearly`)。 +pub fn runtime_profile_membership_product_id( + plan: RuntimeProfileMembershipPlan, + cycle_kind: RuntimeProfileMembershipCycleKind, +) -> String { + format!( + "{}-{}-{}", + MEMBERSHIP_PRODUCT_ID_PREFIX, + plan.as_str(), + cycle_kind.as_str() + ) +} + +/// 会员商品 `product_id` → 「档位 + 周期」;未知 token 一律返回 `None`,不做宽松匹配。 +pub fn parse_runtime_profile_membership_product_id( + product_id: &str, +) -> Option<( + RuntimeProfileMembershipPlan, + RuntimeProfileMembershipCycleKind, +)> { + let rest = product_id.strip_prefix(MEMBERSHIP_PRODUCT_ID_PREFIX)?; + let rest = rest.strip_prefix('-')?; + let (plan_token, cycle_token) = rest.rsplit_once('-')?; + Some(( + RuntimeProfileMembershipPlan::parse(plan_token)?, + RuntimeProfileMembershipCycleKind::parse(cycle_token)?, + )) +} + #[cfg(test)] mod tests { use super::*; use crate::{RuntimeProfileMembershipCycleKind, is_unlimited_concurrency}; - fn row(plan: RuntimeProfileMembershipPlan) -> RuntimeProfileMembershipPlanCatalogRow { + fn row(plan: RuntimeProfileMembershipPlan) -> RuntimeProfileMembershipPlanRecord { resolve_runtime_profile_membership_plan_row(plan).expect("每个档位都应有目录行") } @@ -334,4 +382,59 @@ mod tests { assert!(is_runtime_profile_membership_plan_purchasable(plan)); } } + + #[test] + fn membership_product_id_round_trips_plan_and_cycle() { + for (plan, cycle_kind, product_id) in [ + ( + RuntimeProfileMembershipPlan::Starter, + RuntimeProfileMembershipCycleKind::Monthly, + "membership-starter-monthly", + ), + ( + RuntimeProfileMembershipPlan::Plus, + RuntimeProfileMembershipCycleKind::Yearly, + "membership-plus-yearly", + ), + ( + RuntimeProfileMembershipPlan::Max, + RuntimeProfileMembershipCycleKind::Yearly, + "membership-max-yearly", + ), + ] { + assert_eq!( + runtime_profile_membership_product_id(plan, cycle_kind), + product_id + ); + assert_eq!( + parse_runtime_profile_membership_product_id(product_id), + Some((plan, cycle_kind)) + ); + } + } + + #[test] + fn membership_product_id_rejects_non_membership_tokens() { + // 中文注释:充点商品 `points_*` 与未知 token 都不能被当成会员商品。 + assert_eq!( + parse_runtime_profile_membership_product_id("points_680"), + None + ); + assert_eq!( + parse_runtime_profile_membership_product_id("membership"), + None + ); + assert_eq!( + parse_runtime_profile_membership_product_id("membership-basic-monthly"), + None + ); + assert_eq!( + parse_runtime_profile_membership_product_id("membership-plus-weekly"), + None + ); + assert_eq!( + parse_runtime_profile_membership_product_id("membership-plus"), + None + ); + } } diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index 3f5661eba..65088e2f8 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -15,9 +15,9 @@ use serde::{Deserialize, Serialize}; -use super::catalog::resolve_runtime_profile_membership_plan_snapshot; +use super::catalog::RuntimeProfileMembershipPlanSnapshot; use super::cycle::membership_cycle_remaining_micros; -use crate::{RuntimeProfileMembershipCycleKind, RuntimeProfileMembershipPlan}; +use crate::RuntimeProfileMembershipCycleKind; /// 比例分母:百万分之一。 const REMAINING_RATIO_PPM: u128 = 1_000_000; @@ -25,12 +25,13 @@ const REMAINING_RATIO_PPM: u128 = 1_000_000; const MONTHS_PER_YEAR: u128 = 12; /// 报价接口与下单重算的共用输入。 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +/// +/// 两个套餐快照由调用方从 `profile_membership_plan` 目录表解析后传入, +/// 这样价格与权益的唯一真相源始终是那张可被后台改价的表,而不是编译进代码的种子。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeProfileMembershipUpgradeQuoteInput { - pub current_plan: RuntimeProfileMembershipPlan, - pub current_cycle_kind: RuntimeProfileMembershipCycleKind, - pub target_plan: RuntimeProfileMembershipPlan, - pub target_cycle_kind: RuntimeProfileMembershipCycleKind, + pub current: RuntimeProfileMembershipPlanSnapshot, + pub target: RuntimeProfileMembershipPlanSnapshot, /// 当前期序号(1-based)。 pub cycle_index: u32, /// 本次购买总期数(月付 1 / 年付 12)。 @@ -66,8 +67,6 @@ pub enum RuntimeProfileMembershipUpgradeRejection { NotUpgrade, /// 目标档位不可购买(非会员占位或已下架)。 TargetPlanNotPurchasable, - /// 目录缺少对应档位 / 周期的价格。 - MissingCatalogEntry, } impl RuntimeProfileMembershipUpgradeRejection { @@ -77,7 +76,6 @@ impl RuntimeProfileMembershipUpgradeRejection { Self::CycleKindChangeUnsupported => "cycle_kind_change_unsupported", Self::NotUpgrade => "not_upgrade", Self::TargetPlanNotPurchasable => "target_plan_not_purchasable", - Self::MissingCatalogEntry => "missing_catalog_entry", } } } @@ -100,23 +98,19 @@ pub fn membership_cycle_remaining_ratio_ppm( /// 允许操作矩阵(实施计划 §3.4):只有「已是会员 + 同周期 + 升更高档位 + 目标可购买」才允许升级。 pub fn check_runtime_profile_membership_upgrade_allowed( - current_plan: RuntimeProfileMembershipPlan, - target_plan: RuntimeProfileMembershipPlan, - current_cycle_kind: RuntimeProfileMembershipCycleKind, - target_cycle_kind: RuntimeProfileMembershipCycleKind, + current: &RuntimeProfileMembershipPlanSnapshot, + target: &RuntimeProfileMembershipPlanSnapshot, ) -> Result<(), RuntimeProfileMembershipUpgradeRejection> { - if !current_plan.is_purchasable() { + if !current.plan.is_purchasable() { return Err(RuntimeProfileMembershipUpgradeRejection::NotAMember); } - if current_cycle_kind != target_cycle_kind { + if current.cycle_kind != target.cycle_kind { return Err(RuntimeProfileMembershipUpgradeRejection::CycleKindChangeUnsupported); } - if crate::runtime_profile_membership_plan_rank(target_plan) - <= crate::runtime_profile_membership_plan_rank(current_plan) - { + if target.rank <= current.rank { return Err(RuntimeProfileMembershipUpgradeRejection::NotUpgrade); } - if !crate::is_runtime_profile_membership_plan_purchasable(target_plan) { + if !target.enabled || !target.plan.is_purchasable() { return Err(RuntimeProfileMembershipUpgradeRejection::TargetPlanNotPurchasable); } Ok(()) @@ -126,23 +120,10 @@ pub fn check_runtime_profile_membership_upgrade_allowed( pub fn quote_runtime_profile_membership_upgrade( input: RuntimeProfileMembershipUpgradeQuoteInput, ) -> Result { - check_runtime_profile_membership_upgrade_allowed( - input.current_plan, - input.target_plan, - input.current_cycle_kind, - input.target_cycle_kind, - )?; + check_runtime_profile_membership_upgrade_allowed(&input.current, &input.target)?; - let current = resolve_runtime_profile_membership_plan_snapshot( - input.current_plan, - input.current_cycle_kind, - ) - .ok_or(RuntimeProfileMembershipUpgradeRejection::MissingCatalogEntry)?; - let target = resolve_runtime_profile_membership_plan_snapshot( - input.target_plan, - input.target_cycle_kind, - ) - .ok_or(RuntimeProfileMembershipUpgradeRejection::MissingCatalogEntry)?; + let current = input.current; + let target = input.target; let remaining_full_months = input.cycle_count.saturating_sub(input.cycle_index); let remaining_ratio_ppm = membership_cycle_remaining_ratio_ppm( @@ -154,7 +135,7 @@ pub fn quote_runtime_profile_membership_upgrade( let price_delta_cents = target.price_cents.saturating_sub(current.price_cents); let points_delta = target.period_points.saturating_sub(current.period_points); - let (amount_cents, granted_points_delta) = match input.target_cycle_kind { + let (amount_cents, granted_points_delta) = match target.cycle_kind { RuntimeProfileMembershipCycleKind::Monthly => (price_delta_cents, points_delta), RuntimeProfileMembershipCycleKind::Yearly => { let weighted_months_ppm = u128::from(remaining_full_months) * REMAINING_RATIO_PPM @@ -194,6 +175,8 @@ fn ceil_div(numerator: u128, denominator: u128) -> u64 { #[cfg(test)] mod tests { use super::*; + use crate::RuntimeProfileMembershipPlan; + use crate::membership::catalog::resolve_runtime_profile_membership_plan_snapshot; use crate::membership::cycle::beijing_local_micros; const DAY_MICROS: i64 = 86_400_000_000; @@ -202,14 +185,26 @@ mod tests { beijing_local_micros(year, month, day, hour * 3_600_000_000) } + fn snapshot( + plan: RuntimeProfileMembershipPlan, + cycle_kind: RuntimeProfileMembershipCycleKind, + ) -> RuntimeProfileMembershipPlanSnapshot { + resolve_runtime_profile_membership_plan_snapshot(plan, cycle_kind) + .expect("种子目录里每个档位都应有套餐快照") + } + fn base_input() -> RuntimeProfileMembershipUpgradeQuoteInput { let cycle_started_at_micros = beijing(2026, 6, 20, 9); let cycle_resets_at_micros = beijing(2026, 7, 20, 9); RuntimeProfileMembershipUpgradeQuoteInput { - current_plan: RuntimeProfileMembershipPlan::Plus, - current_cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, - target_plan: RuntimeProfileMembershipPlan::Pro, - target_cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, + current: snapshot( + RuntimeProfileMembershipPlan::Plus, + RuntimeProfileMembershipCycleKind::Yearly, + ), + target: snapshot( + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipCycleKind::Yearly, + ), cycle_index: 3, cycle_count: 9, cycle_started_at_micros, @@ -226,8 +221,14 @@ mod tests { // 中文注释:Plus 月 → Pro 月:补付 ¥200、补点 2500、余额 615 + 2500 = 3115。 let quote = quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { - current_cycle_kind: RuntimeProfileMembershipCycleKind::Monthly, - target_cycle_kind: RuntimeProfileMembershipCycleKind::Monthly, + current: snapshot( + RuntimeProfileMembershipPlan::Plus, + RuntimeProfileMembershipCycleKind::Monthly, + ), + target: snapshot( + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipCycleKind::Monthly, + ), cycle_index: 1, cycle_count: 1, cycle_remaining_points: 615, @@ -257,7 +258,8 @@ mod tests { #[test] fn upgrade_keeps_expiry_reset_and_cycle_index() { let input = base_input(); - let quote = quote_runtime_profile_membership_upgrade(input).expect("年付升级应可报价"); + let quote = + quote_runtime_profile_membership_upgrade(input.clone()).expect("年付升级应可报价"); assert_eq!(quote.expires_at_micros, input.expires_at_micros); assert_eq!(quote.cycle_resets_at_micros, input.cycle_resets_at_micros); assert_eq!(quote.cycle_index, input.cycle_index); @@ -289,6 +291,7 @@ mod tests { ..input }) .expect("年付升级应可报价"); + // 中文注释:此处已把 `input` 整体移入调用,上面的比例断言必须先于本行执行。 assert_eq!(quote.remaining_ratio_ppm, 0); assert_eq!(quote.granted_points_delta, 0); // 中文注释:仅剩 6 个完整月,本期零头为 0,因此只收 6/12 的价差并向下取整补点。 @@ -299,8 +302,14 @@ mod tests { fn non_member_must_purchase_instead_of_upgrading() { assert_eq!( quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { - current_plan: RuntimeProfileMembershipPlan::Normal, - target_plan: RuntimeProfileMembershipPlan::Starter, + current: snapshot( + RuntimeProfileMembershipPlan::Normal, + RuntimeProfileMembershipCycleKind::Yearly, + ), + target: snapshot( + RuntimeProfileMembershipPlan::Starter, + RuntimeProfileMembershipCycleKind::Yearly, + ), ..base_input() }), Err(RuntimeProfileMembershipUpgradeRejection::NotAMember) @@ -311,15 +320,24 @@ mod tests { fn same_plan_and_downgrade_are_rejected() { assert_eq!( quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { - target_plan: RuntimeProfileMembershipPlan::Plus, + target: snapshot( + RuntimeProfileMembershipPlan::Plus, + RuntimeProfileMembershipCycleKind::Yearly, + ), ..base_input() }), Err(RuntimeProfileMembershipUpgradeRejection::NotUpgrade) ); assert_eq!( quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { - current_plan: RuntimeProfileMembershipPlan::Pro, - target_plan: RuntimeProfileMembershipPlan::Plus, + current: snapshot( + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipCycleKind::Yearly, + ), + target: snapshot( + RuntimeProfileMembershipPlan::Plus, + RuntimeProfileMembershipCycleKind::Yearly, + ), ..base_input() }), Err(RuntimeProfileMembershipUpgradeRejection::NotUpgrade) @@ -330,8 +348,14 @@ mod tests { fn cycle_kind_change_is_rejected() { assert_eq!( quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { - current_cycle_kind: RuntimeProfileMembershipCycleKind::Monthly, - target_cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, + current: snapshot( + RuntimeProfileMembershipPlan::Plus, + RuntimeProfileMembershipCycleKind::Monthly, + ), + target: snapshot( + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipCycleKind::Yearly, + ), ..base_input() }), Err(RuntimeProfileMembershipUpgradeRejection::CycleKindChangeUnsupported) @@ -344,16 +368,28 @@ mod tests { // 而不是按 Starter 直接跳 Pro(¥1408.34)。 let starter_to_plus = quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { - current_plan: RuntimeProfileMembershipPlan::Starter, - target_plan: RuntimeProfileMembershipPlan::Plus, + current: snapshot( + RuntimeProfileMembershipPlan::Starter, + RuntimeProfileMembershipCycleKind::Yearly, + ), + target: snapshot( + RuntimeProfileMembershipPlan::Plus, + RuntimeProfileMembershipCycleKind::Yearly, + ), cycle_remaining_points: 0, ..base_input() }) .expect("Starter → Plus 应可报价"); let starter_to_pro = quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { - current_plan: RuntimeProfileMembershipPlan::Starter, - target_plan: RuntimeProfileMembershipPlan::Pro, + current: snapshot( + RuntimeProfileMembershipPlan::Starter, + RuntimeProfileMembershipCycleKind::Yearly, + ), + target: snapshot( + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipCycleKind::Yearly, + ), cycle_remaining_points: 0, ..base_input() }) From 03073564b1d8a25f6e5f7b0e9b8554496d154b2f 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 15:54:28 +0800 Subject: [PATCH 015/232] =?UTF-8?q?=E9=87=8D=E6=9E=84(=E4=BC=9A=E5=91=98):?= =?UTF-8?q?=20=E4=BC=9A=E5=91=98=E8=A1=A8=E5=88=97=E6=94=B9=E4=B8=BA=20pla?= =?UTF-8?q?n=20=E4=B8=8E=E8=87=AA=E7=84=B6=E6=9C=88=E8=B4=A6=E6=9C=9F?= =?UTF-8?q?=EF=BC=8C=E8=AE=A2=E5=8D=95=E8=90=BD=E6=9D=83=E7=9B=8A=E5=8F=98?= =?UTF-8?q?=E6=9B=B4=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit profile_membership 删除 tier / cycle_period_days 与四个会员商品权益列,末位追加 plan、cycle_index、cycle_count、cycle_kind profile_recharge_product_config 删除 tier 与四个会员权益列,只保留充点商品;会员商品不再落该表 profile_recharge_order 末位追加会员变更快照列(变更类型、前后档位、前后周期、补点数、前后到期日 / 刷新日、价格快照) 会员周期刷新改为按北京时间自然月从原始锚点重算,到期清月度余额、永久泥点不受影响 会员购买与升级改走目录表实时价格:有效期内同周期升档按补差报价,补点向下取整、金额向上到分 会员账本幂等锚改为订单维度(membership-period-grant / membership-upgrade-grant 加订单 ID) 充点商品迁移规则改为六档无首充赠送,旧首充赠送行与停用的 ¥128 / ¥328 行一并归一 Co-authored-by: Junie --- server-rs/crates/module-runtime/src/domain.rs | 6 +- .../src/runtime/active/profile.rs | 1086 ++++++++++------- 2 files changed, 618 insertions(+), 474 deletions(-) diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 267ff368e..973adad40 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; -use crate::RuntimeProfileMembershipPlanSnapshot; +use crate::RuntimeProfileMembershipPlanRecord; #[cfg(any())] use serde_json::Value; @@ -1718,7 +1718,7 @@ pub struct RuntimeProfileRechargeCenterSnapshot { pub wallet_balance: u64, pub membership: RuntimeProfileMembershipSnapshot, pub point_products: Vec, - pub membership_plans: Vec, + pub membership_plans: Vec, pub latest_order: Option, pub has_points_recharged: bool, pub daily_free_points: RuntimeProfileDailyFreePointsSnapshot, @@ -2665,7 +2665,7 @@ pub struct RuntimeProfileRechargeCenterRecord { pub wallet_balance: u64, pub membership: RuntimeProfileMembershipRecord, pub point_products: Vec, - pub membership_plans: Vec, + pub membership_plans: Vec, pub latest_order: Option, pub has_points_recharged: bool, pub daily_free_points: RuntimeProfileDailyFreePointsRecord, 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 03e973fe9..d2f9939e4 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -440,7 +440,6 @@ pub struct ProfileMembership { #[primary_key] pub(crate) user_id: String, pub(crate) status: RuntimeProfileMembershipStatus, - pub(crate) tier: RuntimeProfileMembershipTier, pub(crate) started_at: Timestamp, pub(crate) expires_at: Timestamp, pub(crate) updated_at: Timestamp, @@ -452,8 +451,15 @@ pub struct ProfileMembership { pub(crate) cycle_granted_points: u64, #[default(0u64)] pub(crate) cycle_remaining_points: u64, - #[default(30u32)] - pub(crate) cycle_period_days: u32, + // 末位追加:档位身份与自然月期数。旧 `tier` 与固定 30 天的 `cycle_period_days` 已退役。 + #[default(RuntimeProfileMembershipPlan::Normal)] + pub(crate) plan: RuntimeProfileMembershipPlan, + #[default(0u32)] + pub(crate) cycle_index: u32, + #[default(1u32)] + pub(crate) cycle_count: u32, + #[default(RuntimeProfileMembershipCycleKind::Monthly)] + pub(crate) cycle_kind: RuntimeProfileMembershipCycleKind, } #[spacetimedb::table(accessor = profile_recharge_product_config)] @@ -469,21 +475,12 @@ pub struct ProfileRechargeProductConfig { pub(crate) duration_days: u32, pub(crate) badge_label: String, pub(crate) description: String, - pub(crate) tier: RuntimeProfileMembershipTier, pub(crate) enabled: bool, pub(crate) sort_order: i32, pub(crate) created_by: String, pub(crate) created_at: Timestamp, pub(crate) updated_by: String, pub(crate) updated_at: Timestamp, - #[default(0u64)] - pub(crate) membership_period_points: u64, - #[default(0u32)] - pub(crate) membership_period_days: u32, - #[default(0u32)] - pub(crate) membership_queue_limit: u32, - #[default(0u32)] - pub(crate) membership_discount_bps: u32, } #[spacetimedb::table(accessor = profile_membership_plan)] @@ -540,6 +537,35 @@ pub struct ProfileRechargeOrder { pub(crate) expiration_provider_state: Option, #[default(None::)] pub(crate) expiration_last_error: Option, + // 末位追加:会员购买 / 升级的变更快照,人工复核退款时据此还原变更前后的权益。 + #[default(None::)] + pub(crate) membership_change_kind: Option, + #[default(None::)] + pub(crate) membership_plan_before: Option, + #[default(None::)] + pub(crate) membership_plan_after: Option, + #[default(None::)] + pub(crate) membership_cycle_kind_before: Option, + #[default(None::)] + pub(crate) membership_cycle_kind_after: Option, + #[default(None::)] + pub(crate) membership_period_points_before: Option, + #[default(None::)] + pub(crate) membership_period_points_after: Option, + #[default(0i64)] + pub(crate) membership_granted_points_delta: i64, + #[default(None::)] + pub(crate) membership_cycle_index: Option, + #[default(None::)] + pub(crate) membership_expires_at_before: Option, + #[default(None::)] + pub(crate) membership_expires_at_after: Option, + #[default(None::)] + pub(crate) membership_cycle_resets_at_before: Option, + #[default(None::)] + pub(crate) membership_cycle_resets_at_after: Option, + #[default(None::)] + pub(crate) membership_price_breakdown_json: Option, } #[spacetimedb::table( @@ -4651,7 +4677,7 @@ fn create_profile_recharge_order_record( input.created_at_micros, ) .map_err(|error| error.to_string())?; - let product = enabled_profile_recharge_product_by_id(ctx, &validated_input.product_id) + let product = resolve_enabled_profile_recharge_order_product(ctx, &validated_input.product_id) .ok_or_else(|| "recharge.product_id 不存在或已下架".to_string())?; let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros); let should_settle_immediately = @@ -4662,30 +4688,40 @@ fn create_profile_recharge_order_record( &product, created_at, )?; - let (status, paid_at, points_delta, membership_expires_at) = if should_settle_immediately { - let (points_delta, membership_expires_at) = apply_profile_recharge_purchase( + let order_id = build_runtime_profile_recharge_order_id( + &validated_input.user_id, + validated_input.created_at_micros, + &product.product_id, + ); + let (status, paid_at, purchase) = if should_settle_immediately { + let purchase = apply_profile_recharge_purchase( ctx, &validated_input.user_id, &product, + &order_id, + amount_cents, validated_input.created_at_micros, created_at, )?; ( RuntimeProfileRechargeOrderStatus::Paid, Some(created_at), - points_delta, - membership_expires_at, + Some(purchase), ) } else { - (RuntimeProfileRechargeOrderStatus::Pending, None, 0, None) + (RuntimeProfileRechargeOrderStatus::Pending, None, None) + }; + let (points_delta, membership_expires_at, membership_change) = match purchase { + Some(purchase) => ( + purchase.points_delta, + purchase.membership_expires_at, + purchase.membership_change, + ), + None => (0, None, None), }; - let order = ProfileRechargeOrder { - order_id: build_runtime_profile_recharge_order_id( - &validated_input.user_id, - validated_input.created_at_micros, - &product.product_id, - ), + let mut order = ProfileRechargeOrder { + order_id, user_id: validated_input.user_id.clone(), product_id: product.product_id.clone(), product_title: product.title.clone(), @@ -4702,7 +4738,22 @@ fn create_profile_recharge_order_record( expiration_checked_at: None, expiration_provider_state: None, expiration_last_error: None, + membership_change_kind: None, + membership_plan_before: None, + membership_plan_after: None, + membership_cycle_kind_before: None, + membership_cycle_kind_after: None, + membership_period_points_before: None, + membership_period_points_after: None, + membership_granted_points_delta: 0, + membership_cycle_index: None, + membership_expires_at_before: None, + membership_expires_at_after: None, + membership_cycle_resets_at_before: None, + membership_cycle_resets_at_after: None, + membership_price_breakdown_json: None, }; + apply_profile_membership_change_to_order(&mut order, membership_change); ctx.db.profile_recharge_order().insert(order.clone()); if order.status == RuntimeProfileRechargeOrderStatus::Pending && should_schedule_profile_recharge_order_expiration(&order.payment_channel) @@ -4797,13 +4848,15 @@ fn mark_profile_recharge_order_paid_record( return Err("profile_recharge_order 当前状态不能确认支付".to_string()); } - let product = profile_recharge_product_by_id(ctx, &order.product_id) + let product = resolve_profile_recharge_order_product(ctx, &order.product_id) .ok_or_else(|| "recharge.product_id 不存在".to_string())?; let paid_at = Timestamp::from_micros_since_unix_epoch(validated_input.paid_at_micros); - let (points_delta, membership_expires_at) = apply_profile_recharge_purchase( + let purchase = apply_profile_recharge_purchase( ctx, &order.user_id, &product, + &order.order_id, + order.amount_cents, order.created_at.to_micros_since_unix_epoch(), paid_at, )?; @@ -4815,8 +4868,9 @@ fn mark_profile_recharge_order_paid_record( order.status = RuntimeProfileRechargeOrderStatus::Paid; order.paid_at = Some(paid_at); order.provider_transaction_id = validated_input.provider_transaction_id; - order.points_delta = points_delta; - order.membership_expires_at = membership_expires_at; + order.points_delta = purchase.points_delta; + order.membership_expires_at = purchase.membership_expires_at; + apply_profile_membership_change_to_order(&mut order, purchase.membership_change); ctx.db.profile_recharge_order().insert(order.clone()); delete_profile_recharge_order_expiration_task(ctx, &order.order_id); @@ -6844,13 +6898,22 @@ fn delete_profile_recharge_order_expiration_schedule(ctx: &ReducerContext, order .delete(&order_id.to_string()); } +/// 充值 / 会员购买落账结果;会员订单额外带权益变更快照供人工复核。 +struct ProfileRechargePurchaseOutcome { + points_delta: i64, + membership_expires_at: Option, + membership_change: Option, +} + fn apply_profile_recharge_purchase( ctx: &ReducerContext, user_id: &str, product: &RuntimeProfileRechargeProductSnapshot, + order_id: &str, + order_amount_cents: u64, order_created_at_micros: i64, paid_at: Timestamp, -) -> Result<(i64, Option), String> { +) -> Result { match product.kind { RuntimeProfileRechargeProductKind::Points => { let has_recharged = has_profile_product_recharged(ctx, user_id, &product.product_id); @@ -6868,19 +6931,62 @@ fn apply_profile_recharge_purchase( ), paid_at, )?; - Ok((points_delta as i64, None)) + Ok(ProfileRechargePurchaseOutcome { + points_delta: points_delta as i64, + membership_expires_at: None, + membership_change: None, + }) } RuntimeProfileRechargeProductKind::Membership => { - let purchase_result = - apply_profile_membership_purchase(ctx, user_id, product, paid_at)?; - Ok(( - purchase_result.period_points_delta as i64, - Some(purchase_result.expires_at), - )) + let outcome = apply_profile_membership_purchase( + ctx, + user_id, + product, + order_id, + order_amount_cents, + paid_at, + )?; + Ok(ProfileRechargePurchaseOutcome { + points_delta: outcome.period_points_delta as i64, + membership_expires_at: Some(outcome.expires_at), + membership_change: Some(outcome.change), + }) } } } +/// 把会员权益变更快照落到订单行的末位快照列上。 +fn apply_profile_membership_change_to_order( + order: &mut ProfileRechargeOrder, + change: Option, +) { + let Some(change) = change else { + return; + }; + order.membership_change_kind = Some(change.change_kind); + order.membership_plan_before = change.plan_before; + order.membership_plan_after = change.plan_after; + order.membership_cycle_kind_before = change.cycle_kind_before; + order.membership_cycle_kind_after = change.cycle_kind_after; + order.membership_period_points_before = change.period_points_before; + order.membership_period_points_after = change.period_points_after; + order.membership_granted_points_delta = change.granted_points_delta; + order.membership_cycle_index = change.cycle_index; + order.membership_expires_at_before = change + .expires_at_before_micros + .map(Timestamp::from_micros_since_unix_epoch); + order.membership_expires_at_after = change + .expires_at_after_micros + .map(Timestamp::from_micros_since_unix_epoch); + order.membership_cycle_resets_at_before = change + .cycle_resets_at_before_micros + .map(Timestamp::from_micros_since_unix_epoch); + order.membership_cycle_resets_at_after = change + .cycle_resets_at_after_micros + .map(Timestamp::from_micros_since_unix_epoch); + order.membership_price_breakdown_json = change.price_breakdown_json; +} + fn submit_profile_feedback_record( ctx: &ReducerContext, input: RuntimeProfileFeedbackSubmissionInput, @@ -7606,22 +7712,18 @@ fn build_profile_recharge_center_snapshot( let has_points_recharged = has_profile_points_recharged(ctx, user_id); let mut point_products = Vec::new(); - let mut membership_products = Vec::new(); for row in profile_recharge_product_config_rows(ctx, false) { let product = build_profile_recharge_product_snapshot_from_config_row(&row); - match product.kind { - RuntimeProfileRechargeProductKind::Points => { - let has_product_recharged = - has_profile_product_recharged(ctx, user_id, &product.product_id); - point_products.push(resolve_profile_recharge_product_display( - product, - has_product_recharged, - )); - } - RuntimeProfileRechargeProductKind::Membership => { - membership_products.push(product); - } + if product.kind != RuntimeProfileRechargeProductKind::Points { + // 会员商品不在商品配置表里:会员购买 / 升级由 profile_membership_plan 目录表驱动。 + continue; } + let has_product_recharged = + has_profile_product_recharged(ctx, user_id, &product.product_id); + point_products.push(resolve_profile_recharge_product_display( + product, + has_product_recharged, + )); } RuntimeProfileRechargeCenterSnapshot { @@ -7629,8 +7731,7 @@ fn build_profile_recharge_center_snapshot( wallet_balance, membership: build_profile_membership_snapshot(ctx, user_id), point_products, - membership_products, - benefits: runtime_profile_membership_benefits(), + membership_plans: membership_plan_records(ctx), latest_order: latest_profile_recharge_order(ctx, user_id) .map(|row| build_profile_recharge_order_snapshot_from_row(&row)), has_points_recharged, @@ -8475,7 +8576,6 @@ fn ensure_default_profile_recharge_product_config(ctx: &ReducerContext) { let now = ctx.timestamp; for (sort_order, product) in runtime_profile_recharge_point_products() .into_iter() - .chain(runtime_profile_recharge_membership_products()) .enumerate() { ctx.db @@ -8490,40 +8590,32 @@ fn ensure_default_profile_recharge_product_config(ctx: &ReducerContext) { duration_days: product.duration_days, badge_label: product.badge_label, description: product.description, - tier: product.tier, enabled: true, sort_order: sort_order as i32, created_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(), created_at: now, updated_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(), updated_at: now, - membership_period_points: product.membership_period_points, - membership_period_days: product.membership_period_days, - membership_queue_limit: product.membership_queue_limit, - membership_discount_bps: product.membership_discount_bps, }); } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// 旧充点商品行的规则迁移结果。新方案只保留六档「¥1 = 10 泥点、无任何首充赠送」。 struct DefaultPointProductMigration { bonus_points: u64, badge_label: &'static str, - description: &'static str, + description: String, enabled: bool, } fn resolve_default_point_product_migration( row: &ProfileRechargeProductConfig, ) -> Option { + // 只迁移系统播种的充点商品;后台改过的行(`updated_by` 已变)一律不动。 if row.kind != RuntimeProfileRechargeProductKind::Points - || row.tier != RuntimeProfileMembershipTier::Normal || row.updated_by != PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID - || !row.enabled || row.duration_days != 0 - || row.bonus_points != row.points_amount - || row.badge_label != "首充双倍" - || row.description != format!("首充送{}泥点", row.points_amount) + || row.points_amount == 0 { return None; } @@ -8537,39 +8629,26 @@ fn resolve_default_point_product_migration( return None; } - match row.points_amount { - 60 => Some(DefaultPointProductMigration { - bonus_points: 0, - badge_label: "", - description: "60泥点", - enabled: true, - }), - 180 => Some(DefaultPointProductMigration { - bonus_points: 90, - badge_label: "首充加赠", - description: "首充加赠90泥点", - enabled: true, - }), - 300 => Some(DefaultPointProductMigration { - bonus_points: 150, - badge_label: "首充加赠", - description: "首充加赠150泥点", - enabled: true, - }), - 680 => Some(DefaultPointProductMigration { - bonus_points: 340, - badge_label: "首充加赠", - description: "首充加赠340泥点", - enabled: true, - }), - 1_280 | 3_280 => Some(DefaultPointProductMigration { - bonus_points: row.bonus_points, - badge_label: "首充双倍", - description: "", - enabled: false, - }), - _ => None, + let canonical = DefaultPointProductMigration { + bonus_points: 0, + badge_label: "", + description: expected_title, + enabled: true, + }; + + // 旧播种行:首充赠送(赠送 = 面额)+「首充双倍」标签 +「首充送N泥点」说明。 + let is_legacy_bonus_seed = row.enabled + && row.bonus_points == row.points_amount + && row.badge_label == "首充双倍" + && row.description == format!("首充送{}泥点", row.points_amount); + // ¥128 / ¥328 两档曾是「停用的首充双倍行」,现在改为在售且无赠送。 + let is_legacy_disabled_seed = + !row.enabled && row.bonus_points == 0 && row.badge_label == "首充双倍"; + + if is_legacy_bonus_seed || is_legacy_disabled_seed { + return Some(canonical); } + None } fn migrate_legacy_default_profile_recharge_product_config(ctx: &ReducerContext) { @@ -8584,9 +8663,7 @@ fn migrate_legacy_default_profile_recharge_product_config(ctx: &ReducerContext) }; row.bonus_points = migration.bonus_points; row.badge_label = migration.badge_label.to_string(); - if !migration.description.is_empty() { - row.description = migration.description.to_string(); - } + row.description = migration.description; row.enabled = migration.enabled; row.updated_at = ctx.timestamp; ctx.db @@ -8652,10 +8729,55 @@ fn resolve_profile_recharge_product_display( product } +/// 会员订单产生的权益变更快照,落到订单行上供人工复核退款时还原变更前后状态。 #[derive(Clone, Debug, PartialEq, Eq)] -struct ProfileMembershipPurchaseResult { +struct ProfileMembershipChangeSnapshot { + change_kind: RuntimeProfileMembershipChangeKind, + plan_before: Option, + plan_after: Option, + cycle_kind_before: Option, + cycle_kind_after: Option, + period_points_before: Option, + period_points_after: Option, + granted_points_delta: i64, + cycle_index: Option, + expires_at_before_micros: Option, + expires_at_after_micros: Option, + cycle_resets_at_before_micros: Option, + cycle_resets_at_after_micros: Option, + price_breakdown_json: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ProfileMembershipPurchaseOutcome { expires_at: Timestamp, period_points_delta: u64, + change: ProfileMembershipChangeSnapshot, +} + +/// 订单里的价格变更快照(JSON)。人工复核退款时据此确认「按哪个价、补了多少点」。 +fn membership_price_breakdown_json( + target: &RuntimeProfileMembershipPlanSnapshot, + current: Option<&RuntimeProfileMembershipPlanSnapshot>, + order_amount_cents: u64, +) -> String { + serde_json::to_string(&json!({ + "changeKind": match current { + Some(_) => RuntimeProfileMembershipChangeKind::Upgrade, + None => RuntimeProfileMembershipChangeKind::Purchase, + } + .as_str(), + "targetPlan": target.plan.as_str(), + "targetCycleKind": target.cycle_kind.as_str(), + "targetListPriceCents": target.price_cents, + "targetPeriodPoints": target.period_points, + "currentPlan": current.map(|value| value.plan.as_str()), + "currentCycleKind": current.map(|value| value.cycle_kind.as_str()), + "currentListPriceCents": current.map(|value| value.price_cents), + "currentPeriodPoints": current.map(|value| value.period_points), + "orderAmountCents": order_amount_cents, + })) + .unwrap_or_else(|_| PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -8688,134 +8810,206 @@ impl DailyFreePointMutation { } } -struct MembershipCycleInitialization { +/// 会员周期推进结果;`advanced = false` 表示行仍处在本期,无需写账本。 +struct MembershipCycleAdvance { row: ProfileMembership, - granted_points_delta: u64, - cycle_resets_at: Timestamp, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ActiveMembershipPurchaseMode { - Renew, - Upgrade, -} - -fn normalized_membership_period_days(period_days: u32) -> u32 { - if period_days == 0 { - PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS - } else { - period_days - } -} - -fn membership_period_micros(period_days: u32) -> i64 { - i64::from(normalized_membership_period_days(period_days)) - .saturating_mul(PROFILE_RUNTIME_DAY_MICROS) -} - -fn membership_cycle_reset_at(started_at: Timestamp, period_days: u32) -> Timestamp { - Timestamp::from_micros_since_unix_epoch( - started_at - .to_micros_since_unix_epoch() - .saturating_add(membership_period_micros(period_days)), - ) -} - -fn membership_product_period_points(product: &RuntimeProfileRechargeProductSnapshot) -> u64 { - if product.membership_period_points == 0 { - runtime_profile_membership_period_points(product.tier) - } else { - product.membership_period_points - } -} - -fn membership_product_period_days(product: &RuntimeProfileRechargeProductSnapshot) -> u32 { - normalized_membership_period_days(product.membership_period_days) + advanced: bool, + previous_remaining_points: u64, + cycle_started_at_micros: i64, + cycle_resets_at_micros: i64, } fn active_membership_row_at(row: &ProfileMembership, at: Timestamp) -> bool { row.expires_at.to_micros_since_unix_epoch() > at.to_micros_since_unix_epoch() } -fn canonical_membership_pricing_tier( - tier: RuntimeProfileMembershipTier, -) -> RuntimeProfileMembershipTier { - match tier { - RuntimeProfileMembershipTier::Month => RuntimeProfileMembershipTier::Starter, - RuntimeProfileMembershipTier::Season => RuntimeProfileMembershipTier::Basic, - RuntimeProfileMembershipTier::Year => RuntimeProfileMembershipTier::Pro, - _ => tier, - } -} - -fn is_legacy_membership_tier(tier: RuntimeProfileMembershipTier) -> bool { - matches!( - tier, - RuntimeProfileMembershipTier::Month - | RuntimeProfileMembershipTier::Season - | RuntimeProfileMembershipTier::Year - ) -} - -fn active_membership_tier_rank(tier: RuntimeProfileMembershipTier) -> u8 { - runtime_profile_membership_tier_rank(tier).unwrap_or(0) -} - -fn resolve_active_membership_purchase_mode( - current_tier: RuntimeProfileMembershipTier, - next_tier: RuntimeProfileMembershipTier, -) -> Result { - if current_tier == next_tier { - return Ok(ActiveMembershipPurchaseMode::Renew); - } - - let current_rank = active_membership_tier_rank(current_tier); - let next_rank = active_membership_tier_rank(next_tier); - let is_legacy_equivalent_migration = current_rank == next_rank - && is_legacy_membership_tier(current_tier) - && canonical_membership_pricing_tier(current_tier) == next_tier; - - if is_legacy_equivalent_migration { - return Ok(ActiveMembershipPurchaseMode::Renew); - } - - if next_rank > current_rank { - return Ok(ActiveMembershipPurchaseMode::Upgrade); - } - - Err("当前会员有效期内不能购买更低档会员".to_string()) -} - -fn membership_upgrade_amount_cents(target_price_cents: u64, current_price_cents: u64) -> u64 { - target_price_cents.saturating_sub(current_price_cents) -} - -fn membership_product_price_cents_by_tier( +/// 会员目录行读取。`profile_membership_plan` 表是价格与权益的唯一真相源: +/// 代码内种子只在表为空时落一次,之后后台改价不会被覆盖。 +fn membership_plan_row( ctx: &ReducerContext, - tier: RuntimeProfileMembershipTier, -) -> Option { - let canonical_tier = canonical_membership_pricing_tier(tier); - profile_recharge_product_config_rows(ctx, false) - .into_iter() - .find(|row| { - row.kind == RuntimeProfileRechargeProductKind::Membership - && canonical_membership_pricing_tier(row.tier) == canonical_tier - }) - .map(|row| row.price_cents) - .or_else(|| { - runtime_profile_recharge_membership_products() - .into_iter() - .find(|product| canonical_membership_pricing_tier(product.tier) == canonical_tier) - .map(|product| product.price_cents) - }) + plan: RuntimeProfileMembershipPlan, +) -> Option { + ensure_default_profile_membership_plan(ctx); + ctx.db.profile_membership_plan().plan().find(&plan) } +fn membership_plan_period_points(ctx: &ReducerContext, plan: RuntimeProfileMembershipPlan) -> u64 { + membership_plan_row(ctx, plan) + .map(|row| row.period_points) + .unwrap_or(0) +} + +fn membership_plan_snapshot( + ctx: &ReducerContext, + plan: RuntimeProfileMembershipPlan, + cycle_kind: RuntimeProfileMembershipCycleKind, +) -> Option { + membership_plan_row(ctx, plan) + .map(|row| build_runtime_profile_membership_plan_snapshot(&row, cycle_kind)) +} + +/// 充值中心与后台共用的目录行读取,按 `sort_order` 升序。 +fn membership_plan_records(ctx: &ReducerContext) -> Vec { + ensure_default_profile_membership_plan(ctx); + let mut rows = ctx.db.profile_membership_plan().iter().collect::>(); + rows.sort_by(|left, right| { + left.sort_order + .cmp(&right.sort_order) + .then_with(|| left.plan.as_str().cmp(right.plan.as_str())) + }); + rows.into_iter() + .map(|row| RuntimeProfileMembershipPlanRecord { + plan: row.plan, + title: row.title, + rank: row.rank, + month_price_cents: row.month_price_cents, + year_price_cents: row.year_price_cents, + period_points: row.period_points, + model_access: row.model_access, + concurrent_job_limit: row.concurrent_job_limit, + sort_order: row.sort_order, + enabled: row.enabled, + }) + .collect() +} + +/// 把会员行推进到 `at` 时刻所在的那一期。 +/// +/// 每一期都从**原始锚点**(`started_at`)按自然月重算,因此 1/31 开通得到 +/// 1/31 → 2/28 → 3/31,不会像按固定天数递推那样漂移成 3/28。 +fn advance_profile_membership_cycle_to( + row: ProfileMembership, + at: Timestamp, + period_points: u64, +) -> MembershipCycleAdvance { + let mut row = row; + let anchor_micros = row.started_at.to_micros_since_unix_epoch(); + let at_micros = at.to_micros_since_unix_epoch(); + let previous_remaining_points = row.cycle_remaining_points; + let max_index = row.cycle_count.max(1).saturating_add(2); + let mut cycle_index = row.cycle_index.max(1); + let mut window = membership_cycle_window(anchor_micros, cycle_index); + let mut advanced = row.cycle_resets_at.is_none() || row.cycle_index == 0; + while window.1 <= at_micros && cycle_index < max_index { + cycle_index = cycle_index.saturating_add(1); + window = membership_cycle_window(anchor_micros, cycle_index); + advanced = true; + } + if advanced { + row.cycle_index = cycle_index; + row.cycle_started_at = Some(Timestamp::from_micros_since_unix_epoch(window.0)); + row.cycle_resets_at = Some(Timestamp::from_micros_since_unix_epoch(window.1)); + row.cycle_granted_points = period_points; + row.cycle_remaining_points = period_points; + row.updated_at = at; + } + MembershipCycleAdvance { + row, + advanced, + previous_remaining_points, + cycle_started_at_micros: window.0, + cycle_resets_at_micros: window.1, + } +} + +/// 升级 / 报价共用的纯函数入参:两个套餐快照都来自目录表。 +fn membership_upgrade_quote_input( + row: &ProfileMembership, + current: &RuntimeProfileMembershipPlanSnapshot, + target: &RuntimeProfileMembershipPlanSnapshot, + now: Timestamp, +) -> RuntimeProfileMembershipUpgradeQuoteInput { + RuntimeProfileMembershipUpgradeQuoteInput { + current: current.clone(), + target: target.clone(), + cycle_index: row.cycle_index.max(1), + cycle_count: row.cycle_count.max(1), + cycle_started_at_micros: row + .cycle_started_at + .map(|value| value.to_micros_since_unix_epoch()) + .unwrap_or_else(|| row.started_at.to_micros_since_unix_epoch()), + cycle_resets_at_micros: row + .cycle_resets_at + .map(|value| value.to_micros_since_unix_epoch()) + .unwrap_or_else(|| row.expires_at.to_micros_since_unix_epoch()), + expires_at_micros: row.expires_at.to_micros_since_unix_epoch(), + cycle_remaining_points: row.cycle_remaining_points, + now_micros: now.to_micros_since_unix_epoch(), + } +} + +fn upsert_profile_membership_row(ctx: &ReducerContext, row: ProfileMembership) { + ctx.db.profile_membership().user_id().delete(&row.user_id); + ctx.db.profile_membership().insert(row); +} + +/// 会员商品在订单里的商品快照。会员商品没有 `profile_recharge_product_config` 行: +/// `product_id` 就是「档位 + 周期」,标题与价格来自 `profile_membership_plan` 目录表。 +fn build_membership_order_product( + row: &ProfileMembershipPlan, + plan: RuntimeProfileMembershipPlan, + cycle_kind: RuntimeProfileMembershipCycleKind, +) -> RuntimeProfileRechargeProductSnapshot { + let snapshot = build_runtime_profile_membership_plan_snapshot(row, cycle_kind); + let cycle_label = match cycle_kind { + RuntimeProfileMembershipCycleKind::Monthly => "月付", + RuntimeProfileMembershipCycleKind::Yearly => "年付", + }; + RuntimeProfileRechargeProductSnapshot { + product_id: runtime_profile_membership_product_id(plan, cycle_kind), + title: format!("{}·{}", snapshot.title, cycle_label), + price_cents: snapshot.price_cents, + kind: RuntimeProfileRechargeProductKind::Membership, + points_amount: 0, + bonus_points: 0, + duration_days: 0, + badge_label: String::new(), + description: snapshot.title, + } +} + +/// 下单时可购买的商品:会员商品要求档位在售,充点商品要求行已启用。 +fn resolve_enabled_profile_recharge_order_product( + ctx: &ReducerContext, + product_id: &str, +) -> Option { + if let Some((plan, cycle_kind)) = parse_runtime_profile_membership_product_id(product_id) { + let row = membership_plan_row(ctx, plan)?; + if !(row.enabled && plan.is_purchasable()) { + return None; + } + return Some(build_membership_order_product(&row, plan, cycle_kind)); + } + enabled_profile_recharge_product_by_id(ctx, product_id) +} + +/// 支付确认时解析订单商品:会员档位即使下单后被下架,已支付订单仍按原档位结算。 +fn resolve_profile_recharge_order_product( + ctx: &ReducerContext, + product_id: &str, +) -> Option { + if let Some((plan, cycle_kind)) = parse_runtime_profile_membership_product_id(product_id) { + let row = membership_plan_row(ctx, plan)?; + return Some(build_membership_order_product(&row, plan, cycle_kind)); + } + profile_recharge_product_by_id(ctx, product_id) +} + +/// 会员订单应付金额:首次购买按目录价;有效期内升级按补差报价重算。 fn resolve_profile_membership_order_amount_cents( ctx: &ReducerContext, user_id: &str, - product: &RuntimeProfileRechargeProductSnapshot, + product_id: &str, created_at: Timestamp, ) -> Result { + let (plan, cycle_kind) = parse_runtime_profile_membership_product_id(product_id) + .ok_or_else(|| format!("会员商品 product_id 无法解析: {product_id}"))?; + let target = membership_plan_snapshot(ctx, plan, cycle_kind) + .ok_or_else(|| format!("会员目录缺少档位 {}", plan.as_str()))?; + if !target.enabled { + return Err(format!("会员档位 {} 当前不可购买", plan.as_str())); + } + let Some(row) = ctx .db .profile_membership() @@ -8823,19 +9017,21 @@ fn resolve_profile_membership_order_amount_cents( .find(&user_id.to_string()) .filter(|row| active_membership_row_at(row, created_at)) else { - return Ok(product.price_cents); + return Ok(target.price_cents); }; - - let purchase_mode = resolve_active_membership_purchase_mode(row.tier, product.tier)?; - if purchase_mode == ActiveMembershipPurchaseMode::Renew { - return Ok(product.price_cents); + if row.plan == plan { + return Err("当前套餐已生效,无需重复购买".to_string()); } - - let current_price_cents = membership_product_price_cents_by_tier(ctx, row.tier).unwrap_or(0); - Ok(membership_upgrade_amount_cents( - product.price_cents, - current_price_cents, + if row.cycle_kind != cycle_kind { + return Err("当前会员有效期内不支持月付 / 年付转换,到期后可重新选择".to_string()); + } + let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind) + .ok_or_else(|| format!("会员目录缺少档位 {}", row.plan.as_str()))?; + let quote = quote_runtime_profile_membership_upgrade(membership_upgrade_quote_input( + &row, ¤t, &target, created_at, )) + .map_err(|rejection| format!("会员升级被拒绝: {}", rejection.as_str()))?; + Ok(quote.amount_cents) } fn resolve_profile_recharge_order_amount_cents( @@ -8847,91 +9043,16 @@ fn resolve_profile_recharge_order_amount_cents( match product.kind { RuntimeProfileRechargeProductKind::Points => Ok(product.price_cents), RuntimeProfileRechargeProductKind::Membership => { - resolve_profile_membership_order_amount_cents(ctx, user_id, product, created_at) + resolve_profile_membership_order_amount_cents( + ctx, + user_id, + &product.product_id, + created_at, + ) } } } -fn apply_active_membership_upgrade_row( - mut row: ProfileMembership, - tier: RuntimeProfileMembershipTier, - period_days: u32, - period_points: u64, - updated_at: Timestamp, -) -> (ProfileMembership, u64) { - let period_points_delta = period_points.saturating_sub(row.cycle_granted_points); - row.tier = tier; - row.cycle_granted_points = row.cycle_granted_points.max(period_points); - row.cycle_remaining_points = row - .cycle_remaining_points - .saturating_add(period_points_delta); - row.cycle_period_days = period_days; - row.updated_at = updated_at; - (row, period_points_delta) -} - -fn apply_active_membership_renew_row( - mut row: ProfileMembership, - tier: RuntimeProfileMembershipTier, - duration_days: u32, - purchased_at: Timestamp, -) -> ProfileMembership { - row.tier = tier; - row.expires_at = Timestamp::from_micros_since_unix_epoch( - row.expires_at - .to_micros_since_unix_epoch() - .saturating_add(i64::from(duration_days).saturating_mul(PROFILE_RUNTIME_DAY_MICROS)), - ); - row.updated_at = purchased_at; - row -} - -fn upsert_profile_membership_row(ctx: &ReducerContext, row: ProfileMembership) { - ctx.db.profile_membership().user_id().delete(&row.user_id); - ctx.db.profile_membership().insert(row); -} - -fn initialize_missing_profile_membership_cycle( - mut row: ProfileMembership, - now: Timestamp, -) -> Option { - if row.cycle_resets_at.is_some() { - return None; - } - - let period_days = normalized_membership_period_days(row.cycle_period_days); - let period_micros = membership_period_micros(period_days); - let mut cycle_started_at = row.cycle_started_at.unwrap_or(row.started_at); - let mut next_reset_at = membership_cycle_reset_at(cycle_started_at, period_days); - let now_micros = now.to_micros_since_unix_epoch(); - let mut guard = 0u32; - while next_reset_at.to_micros_since_unix_epoch() <= now_micros && guard < 120 { - cycle_started_at = next_reset_at; - next_reset_at = Timestamp::from_micros_since_unix_epoch( - next_reset_at - .to_micros_since_unix_epoch() - .saturating_add(period_micros), - ); - guard = guard.saturating_add(1); - } - - let granted_points = - runtime_profile_membership_period_points(row.tier).max(row.cycle_granted_points); - let granted_points_delta = granted_points.saturating_sub(row.cycle_remaining_points); - row.cycle_started_at = Some(cycle_started_at); - row.cycle_resets_at = Some(next_reset_at); - row.cycle_granted_points = granted_points; - row.cycle_remaining_points = granted_points; - row.cycle_period_days = period_days; - row.updated_at = now; - - Some(MembershipCycleInitialization { - row, - granted_points_delta, - cycle_resets_at: next_reset_at, - }) -} - fn membership_cycle_metadata( action: &str, expired_points: u64, @@ -9196,8 +9317,12 @@ fn refresh_profile_wallet_expiring_points( refresh_profile_membership_cycle(ctx, user_id, membership_now); } +/// 会员周期刷新。 +/// +/// - 已到期:清月度余额并置为非会员(永久泥点不受影响)。 +/// - 未到期:按北京时间自然月从**原始锚点**推进到当前期,清上期余额再发新额度。 fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Timestamp) { - let Some(mut row) = ctx + let Some(row) = ctx .db .profile_membership() .user_id() @@ -9211,11 +9336,12 @@ fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Ti if expires_at_micros <= now_micros { let expired_points = row.cycle_remaining_points; if row.status != RuntimeProfileMembershipStatus::Normal || expired_points > 0 { - row.status = RuntimeProfileMembershipStatus::Normal; - row.cycle_remaining_points = 0; - row.updated_at = now; + let mut expired_row = row; + expired_row.status = RuntimeProfileMembershipStatus::Normal; + expired_row.cycle_remaining_points = 0; + expired_row.updated_at = now; let ledger_id = format!("membership-period-expire:{user_id}:{expires_at_micros}"); - upsert_profile_membership_row(ctx, row); + upsert_profile_membership_row(ctx, expired_row); update_profile_wallet_balance_for_expiring_points( ctx, user_id, @@ -9230,72 +9356,19 @@ fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Ti return; } - if row.cycle_resets_at.is_none() { - let Some(initialization) = initialize_missing_profile_membership_cycle(row, now) else { - return; - }; - let ledger_id = format!( - "membership-period-initialize:{user_id}:{}", - initialization.cycle_resets_at.to_micros_since_unix_epoch() - ); - let granted_points_delta = initialization.granted_points_delta; - row = initialization.row; - let cycle_resets_at = row.cycle_resets_at.clone(); - upsert_profile_membership_row(ctx, row); - update_profile_wallet_balance_for_expiring_points( - ctx, - user_id, - 0, - granted_points_delta, - RuntimeProfileWalletLedgerSourceType::MembershipPeriodGrant, - &ledger_id, - now, - membership_cycle_metadata("initialize", 0, granted_points_delta, cycle_resets_at), - ); - let Some(refreshed_row) = ctx - .db - .profile_membership() - .user_id() - .find(&user_id.to_string()) - else { - return; - }; - row = refreshed_row; - } - - let Some(mut next_reset_at) = row.cycle_resets_at else { - return; - }; - if next_reset_at.to_micros_since_unix_epoch() > now_micros { + let period_points = membership_plan_period_points(ctx, row.plan); + let advance = advance_profile_membership_cycle_to(row, now, period_points); + if !advance.advanced { return; } - - let period_days = normalized_membership_period_days(row.cycle_period_days); - let period_micros = membership_period_micros(period_days); - let mut next_cycle_started_at = next_reset_at; - let mut guard = 0u32; - while next_reset_at.to_micros_since_unix_epoch() <= now_micros && guard < 120 { - next_cycle_started_at = next_reset_at; - next_reset_at = Timestamp::from_micros_since_unix_epoch( - next_reset_at - .to_micros_since_unix_epoch() - .saturating_add(period_micros), - ); - guard = guard.saturating_add(1); - } - - let expired_points = row.cycle_remaining_points; - let granted_points = - runtime_profile_membership_period_points(row.tier).max(row.cycle_granted_points); - row.cycle_started_at = Some(next_cycle_started_at); - row.cycle_resets_at = Some(next_reset_at); - row.cycle_granted_points = granted_points; - row.cycle_remaining_points = granted_points; - row.cycle_period_days = period_days; - row.updated_at = now; - let reset_micros = next_cycle_started_at.to_micros_since_unix_epoch(); - let ledger_id = format!("membership-period-reset:{user_id}:{reset_micros}"); - upsert_profile_membership_row(ctx, row); + let expired_points = advance.previous_remaining_points; + let granted_points = advance.row.cycle_granted_points; + let next_reset_at = advance.row.cycle_resets_at; + let ledger_id = format!( + "membership-period-reset:{user_id}:{}", + advance.cycle_started_at_micros + ); + upsert_profile_membership_row(ctx, advance.row); update_profile_wallet_balance_for_expiring_points( ctx, user_id, @@ -9304,7 +9377,7 @@ fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Ti RuntimeProfileWalletLedgerSourceType::MembershipPeriodReset, &ledger_id, now, - membership_cycle_metadata("reset", expired_points, granted_points, Some(next_reset_at)), + membership_cycle_metadata("reset", expired_points, granted_points, next_reset_at), ); } @@ -9324,7 +9397,7 @@ fn build_profile_membership_snapshot( RuntimeProfileMembershipSnapshot { user_id: row.user_id, status: row.status, - tier: row.tier, + plan: row.plan, started_at_micros: Some(row.started_at.to_micros_since_unix_epoch()), expires_at_micros: Some(row.expires_at.to_micros_since_unix_epoch()), updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()), @@ -9336,13 +9409,16 @@ fn build_profile_membership_snapshot( .map(|value| value.to_micros_since_unix_epoch()), cycle_granted_points: row.cycle_granted_points, cycle_remaining_points: row.cycle_remaining_points, - cycle_period_days: normalized_membership_period_days(row.cycle_period_days), + cycle_index: row.cycle_index.max(1), + cycle_count: row.cycle_count.max(1), + cycle_kind: row.cycle_kind, } } + // 中文注释:到期后对外只暴露“非会员”,但保留原周期信息供展示“已失效的套餐”。 Some(row) => RuntimeProfileMembershipSnapshot { user_id: row.user_id, status: RuntimeProfileMembershipStatus::Normal, - tier: RuntimeProfileMembershipTier::Normal, + plan: RuntimeProfileMembershipPlan::Normal, started_at_micros: Some(row.started_at.to_micros_since_unix_epoch()), expires_at_micros: Some(row.expires_at.to_micros_since_unix_epoch()), updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()), @@ -9354,12 +9430,14 @@ fn build_profile_membership_snapshot( .map(|value| value.to_micros_since_unix_epoch()), cycle_granted_points: row.cycle_granted_points, cycle_remaining_points: 0, - cycle_period_days: normalized_membership_period_days(row.cycle_period_days), + cycle_index: row.cycle_index.max(1), + cycle_count: row.cycle_count.max(1), + cycle_kind: row.cycle_kind, }, None => RuntimeProfileMembershipSnapshot { user_id: user_id.to_string(), status: RuntimeProfileMembershipStatus::Normal, - tier: RuntimeProfileMembershipTier::Normal, + plan: RuntimeProfileMembershipPlan::Normal, started_at_micros: None, expires_at_micros: None, updated_at_micros: None, @@ -9367,116 +9445,192 @@ fn build_profile_membership_snapshot( cycle_resets_at_micros: None, cycle_granted_points: 0, cycle_remaining_points: 0, - cycle_period_days: PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS, + cycle_index: 1, + cycle_count: 1, + cycle_kind: RuntimeProfileMembershipCycleKind::Monthly, }, } } +/// 会员购买 / 补差升级写路径。 +/// +/// - 有效期内:只允许「同周期升更高档位」,按目录表实时价格算补差、补点,刷新日与到期日不变。 +/// - 无有效会员(从未开通或已到期):按完整周期重新起算,锚点就是本次支付时刻。 +/// +/// 幂等锚点是**订单维度**的账本 id,重复支付回调天然幂等。 fn apply_profile_membership_purchase( ctx: &ReducerContext, user_id: &str, product: &RuntimeProfileRechargeProductSnapshot, + order_id: &str, + order_amount_cents: u64, purchased_at: Timestamp, -) -> Result { +) -> Result { + let (plan, cycle_kind) = parse_runtime_profile_membership_product_id(&product.product_id) + .ok_or_else(|| format!("会员商品 product_id 无法解析: {}", product.product_id))?; + let target = membership_plan_snapshot(ctx, plan, cycle_kind) + .ok_or_else(|| format!("会员目录缺少档位 {}", plan.as_str()))?; + if !target.enabled { + return Err(format!("会员档位 {} 当前不可购买", plan.as_str())); + } + refresh_profile_wallet_expiring_points(ctx, user_id, purchased_at); - let tier = product.tier; - let duration_days = product.duration_days; - let period_days = membership_product_period_days(product); - let period_points = membership_product_period_points(product); - let current = ctx + + let active_row = ctx .db .profile_membership() .user_id() - .find(&user_id.to_string()); - let purchased_at_micros = purchased_at.to_micros_since_unix_epoch(); - let duration_micros = i64::from(duration_days).saturating_mul(PROFILE_RUNTIME_DAY_MICROS); + .find(&user_id.to_string()) + .filter(|row| active_membership_row_at(row, purchased_at)); - let (next_row, period_points_delta) = match current { - Some(row) if active_membership_row_at(&row, purchased_at) => { - let purchase_mode = resolve_active_membership_purchase_mode(row.tier, tier)?; - if purchase_mode == ActiveMembershipPurchaseMode::Renew { - ( - apply_active_membership_renew_row(row, tier, duration_days, purchased_at), - 0, - ) - } else { - apply_active_membership_upgrade_row( - row, - tier, - period_days, - period_points, - purchased_at, - ) - } + if let Some(row) = active_row { + if row.plan == plan { + return Err("当前套餐已生效,无需重复购买".to_string()); } - Some(row) => { - let expires_at = Timestamp::from_micros_since_unix_epoch( - purchased_at_micros.saturating_add(duration_micros), - ); - ( - ProfileMembership { - user_id: row.user_id, - status: RuntimeProfileMembershipStatus::Active, - tier, - started_at: purchased_at, - expires_at, - updated_at: purchased_at, - cycle_started_at: Some(purchased_at), - cycle_resets_at: Some(membership_cycle_reset_at(purchased_at, period_days)), - cycle_granted_points: period_points, - cycle_remaining_points: period_points, - cycle_period_days: period_days, - }, - period_points, - ) + if row.cycle_kind != cycle_kind { + return Err("当前会员有效期内不支持月付 / 年付转换,到期后可重新选择".to_string()); } - None => { - let expires_at = Timestamp::from_micros_since_unix_epoch( - purchased_at_micros.saturating_add(duration_micros), - ); - ( - ProfileMembership { - user_id: user_id.to_string(), - status: RuntimeProfileMembershipStatus::Active, - tier, - started_at: purchased_at, - expires_at, - updated_at: purchased_at, - cycle_started_at: Some(purchased_at), - cycle_resets_at: Some(membership_cycle_reset_at(purchased_at, period_days)), - cycle_granted_points: period_points, - cycle_remaining_points: period_points, - cycle_period_days: period_days, - }, - period_points, - ) - } - }; + let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind) + .ok_or_else(|| format!("会员目录缺少档位 {}", row.plan.as_str()))?; + let quote = quote_runtime_profile_membership_upgrade(membership_upgrade_quote_input( + &row, + ¤t, + &target, + purchased_at, + )) + .map_err(|rejection| format!("会员升级被拒绝: {}", rejection.as_str()))?; - let expires_at = next_row.expires_at; - let cycle_resets_at = next_row.cycle_resets_at.clone(); - upsert_profile_membership_row(ctx, next_row); + let expires_at_before_micros = row.expires_at.to_micros_since_unix_epoch(); + let cycle_resets_at_before_micros = row + .cycle_resets_at + .map(|value| value.to_micros_since_unix_epoch()); + let mut next_row = row; + // 已用点不返还、永久泥点不动:只把档位换成新档,并追加本期补点。 + next_row.plan = plan; + next_row.cycle_kind = cycle_kind; + next_row.cycle_count = cycle_kind.cycle_count(); + next_row.cycle_granted_points = next_row.cycle_granted_points.max(target.period_points); + next_row.cycle_remaining_points = next_row + .cycle_remaining_points + .saturating_add(quote.granted_points_delta); + next_row.updated_at = purchased_at; + let expires_at = next_row.expires_at; + let cycle_resets_at = next_row.cycle_resets_at; + let cycle_index = next_row.cycle_index.max(1); + upsert_profile_membership_row(ctx, next_row); - if period_points_delta > 0 { - let ledger_id = format!( - "membership-period-grant:{user_id}:{}:{}", - purchased_at_micros, product.product_id - ); + let ledger_id = format!("membership-upgrade-grant:{user_id}:{order_id}"); update_profile_wallet_balance_for_expiring_points( ctx, user_id, 0, - period_points_delta, - RuntimeProfileWalletLedgerSourceType::MembershipPeriodGrant, + quote.granted_points_delta, + RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant, &ledger_id, purchased_at, - membership_cycle_metadata("purchase", 0, period_points_delta, cycle_resets_at), + membership_cycle_metadata("upgrade", 0, quote.granted_points_delta, cycle_resets_at), ); + + return Ok(ProfileMembershipPurchaseOutcome { + expires_at, + period_points_delta: quote.granted_points_delta, + change: ProfileMembershipChangeSnapshot { + change_kind: RuntimeProfileMembershipChangeKind::Upgrade, + plan_before: Some(current.plan), + plan_after: Some(plan), + cycle_kind_before: Some(current.cycle_kind), + cycle_kind_after: Some(cycle_kind), + period_points_before: Some(current.period_points), + period_points_after: Some(target.period_points), + granted_points_delta: quote.granted_points_delta as i64, + cycle_index: Some(cycle_index), + expires_at_before_micros: Some(expires_at_before_micros), + expires_at_after_micros: Some(expires_at.to_micros_since_unix_epoch()), + cycle_resets_at_before_micros, + cycle_resets_at_after_micros: cycle_resets_at + .map(|value| value.to_micros_since_unix_epoch()), + price_breakdown_json: Some(membership_price_breakdown_json( + &target, + Some(¤t), + order_amount_cents, + )), + }, + }); } - Ok(ProfileMembershipPurchaseResult { + // 无有效会员:全新起算。到期未续的旧行会被整行覆盖(月度余额清零,永久泥点不受影响)。 + let expired_existing = ctx + .db + .profile_membership() + .user_id() + .find(&user_id.to_string()); + let anchor_micros = purchased_at.to_micros_since_unix_epoch(); + let (cycle_started_at_micros, cycle_resets_at_micros) = + membership_cycle_window(anchor_micros, 1); + let expires_at_micros = membership_expires_at(anchor_micros, cycle_kind); + let expires_at = Timestamp::from_micros_since_unix_epoch(expires_at_micros); + let cycle_resets_at = Timestamp::from_micros_since_unix_epoch(cycle_resets_at_micros); + let next_row = ProfileMembership { + user_id: user_id.to_string(), + status: RuntimeProfileMembershipStatus::Active, + started_at: purchased_at, expires_at, - period_points_delta, + updated_at: purchased_at, + cycle_started_at: Some(Timestamp::from_micros_since_unix_epoch( + cycle_started_at_micros, + )), + cycle_resets_at: Some(cycle_resets_at), + cycle_granted_points: target.period_points, + cycle_remaining_points: target.period_points, + plan, + cycle_index: 1, + cycle_count: cycle_kind.cycle_count(), + cycle_kind, + }; + upsert_profile_membership_row(ctx, next_row); + + let ledger_id = format!("membership-period-grant:{user_id}:{order_id}"); + update_profile_wallet_balance_for_expiring_points( + ctx, + user_id, + 0, + target.period_points, + RuntimeProfileWalletLedgerSourceType::MembershipPeriodGrant, + &ledger_id, + purchased_at, + membership_cycle_metadata("purchase", 0, target.period_points, Some(cycle_resets_at)), + ); + + Ok(ProfileMembershipPurchaseOutcome { + expires_at, + period_points_delta: target.period_points, + change: ProfileMembershipChangeSnapshot { + change_kind: RuntimeProfileMembershipChangeKind::Purchase, + plan_before: expired_existing.as_ref().map(|row| row.plan), + plan_after: Some(plan), + cycle_kind_before: expired_existing.as_ref().map(|row| row.cycle_kind), + cycle_kind_after: Some(cycle_kind), + period_points_before: expired_existing + .as_ref() + .map(|row| row.cycle_granted_points), + period_points_after: Some(target.period_points), + granted_points_delta: target.period_points as i64, + cycle_index: Some(1), + expires_at_before_micros: expired_existing + .as_ref() + .map(|row| row.expires_at.to_micros_since_unix_epoch()), + expires_at_after_micros: Some(expires_at_micros), + cycle_resets_at_before_micros: expired_existing + .as_ref() + .and_then(|row| row.cycle_resets_at) + .map(|value| value.to_micros_since_unix_epoch()), + cycle_resets_at_after_micros: Some(cycle_resets_at_micros), + price_breakdown_json: Some(membership_price_breakdown_json( + &target, + None, + order_amount_cents, + )), + }, }) } @@ -10993,11 +11147,6 @@ fn build_profile_recharge_product_config_snapshot_from_row( duration_days: row.duration_days, badge_label: row.badge_label.clone(), description: row.description.clone(), - tier: row.tier, - membership_period_points: row.membership_period_points, - membership_period_days: row.membership_period_days, - membership_queue_limit: row.membership_queue_limit, - membership_discount_bps: row.membership_discount_bps, enabled: row.enabled, sort_order: row.sort_order, created_by: row.created_by.clone(), @@ -11020,11 +11169,6 @@ fn build_profile_recharge_product_snapshot_from_config_row( duration_days: row.duration_days, badge_label: row.badge_label.clone(), description: row.description.clone(), - tier: row.tier, - membership_period_points: row.membership_period_points, - membership_period_days: row.membership_period_days, - membership_queue_limit: row.membership_queue_limit, - membership_discount_bps: row.membership_discount_bps, } } From e98564e13c5f44b1f25a220bd32fe42ff33a8eb6 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 16:59:34 +0800 Subject: [PATCH 016/232] =?UTF-8?q?=E9=87=8D=E6=9E=84(=E4=BC=9A=E5=91=98):?= =?UTF-8?q?=20=E4=BC=9A=E5=91=98=E7=9B=AE=E5=BD=95=E4=B8=8E=E8=87=AA?= =?UTF-8?q?=E7=84=B6=E6=9C=88=E8=B4=A6=E6=9C=9F=E8=90=BD=E5=88=B0=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E4=B8=8E=E9=A2=86=E5=9F=9F=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 领域层新增会员档位枚举、自然月账期、目录快照与补差报价入参,删除旧 tier 相关函数 - profile_membership 表以 plan 与自然月期数取代 tier 与固定 30 天周期 - profile_recharge_order 末位追加会员权益变更快照,供人工复核退款 - profile_membership_plan 目录表成为价格与权益的唯一真相源,新增后台读写接口 - 充点商品收敛为六档且取消首充赠送,旧播种行按新规则迁移 - 充值中心改为输出会员目录,不再输出会员商品行与权益矩阵 - 重新生成 SpacetimeDB 绑定并同步客户端映射与 facade Co-authored-by: Junie --- .../crates/module-runtime/src/commands.rs | 63 +++ server-rs/crates/module-runtime/src/domain.rs | 66 +++ server-rs/crates/module-runtime/src/errors.rs | 4 + .../crates/spacetime-client/src/active.rs | 2 + .../src/active/mapper/runtime_profile.rs | 268 ++++++---- .../spacetime-client/src/active/runtime.rs | 90 +++- .../spacetime-client/src/module_bindings.rs | 36 +- ...list_profile_membership_plans_procedure.rs | 61 +++ ...psert_profile_membership_plan_procedure.rs | 62 +++ ...bution_user_review_and_return_procedure.rs | 16 +- ...ribution_my_review_and_return_procedure.rs | 16 +- ...ution_user_reviews_and_return_procedure.rs | 16 +- .../list_agc_tracking_events_procedure.rs | 16 +- ...ution_review_games_and_return_procedure.rs | 16 +- ...ution_user_reviews_and_return_procedure.rs | 16 +- ...bution_user_review_and_return_procedure.rs | 16 +- .../profile_membership_table.rs | 3 +- .../profile_membership_type.rs | 22 +- .../profile_recharge_order_table.rs | 3 + .../profile_recharge_order_type.rs | 105 ++++ .../profile_recharge_product_config_table.rs | 1 - .../profile_recharge_product_config_type.rs | 29 -- ...me_profile_membership_change_kind_type.rs} | 20 +- ...ime_profile_membership_cycle_kind_type.rs} | 17 +- ...e_membership_order_change_snapshot_type.rs | 28 + ...e_membership_plan_admin_list_input_type.rs | 15 + ...p_plan_admin_list_procedure_result_type.rs | 19 + ...ership_plan_admin_procedure_result_type.rs | 19 + ...membership_plan_admin_upsert_input_type.rs | 28 + ...ime_profile_membership_plan_record_type.rs | 27 + ...untime_profile_membership_snapshot_type.rs | 9 +- ...e_profile_recharge_center_snapshot_type.rs | 5 +- ...echarge_order_admin_entry_snapshot_type.rs | 2 + ...echarge_product_admin_upsert_input_type.rs | 6 - ...e_recharge_product_config_snapshot_type.rs | 6 - ..._profile_recharge_product_snapshot_type.rs | 6 - ...ribution_my_review_and_return_procedure.rs | 16 +- .../upload_agc_analytics_batch_procedure.rs | 16 +- .../src/runtime/active/profile.rs | 492 ++++++++++-------- 39 files changed, 1176 insertions(+), 482 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_membership_plans_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_membership_plan_procedure.rs rename server-rs/crates/spacetime-client/src/module_bindings/{runtime_profile_membership_tier_type.rs => runtime_profile_membership_change_kind_type.rs} (68%) rename server-rs/crates/spacetime-client/src/module_bindings/{runtime_profile_membership_benefit_snapshot_type.rs => runtime_profile_membership_cycle_kind_type.rs} (50%) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_order_change_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_list_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_upsert_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_record_type.rs diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index bab1dff70..ce8f521f3 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -436,6 +436,69 @@ pub fn build_runtime_profile_recharge_product_admin_upsert_input( }) } +pub fn build_runtime_profile_membership_plan_admin_list_input( + admin_user_id: String, +) -> Result { + let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; + Ok(RuntimeProfileMembershipPlanAdminListInput { admin_user_id }) +} + +/// 后台会员档位改价 / 改权益入参校验。 +/// +/// `normal` 是不可购买的非会员占位档位:它的价格与额度没有意义,一律按 0 落库。 +#[allow(clippy::too_many_arguments)] +pub fn build_runtime_profile_membership_plan_admin_upsert_input( + admin_user_id: String, + plan: RuntimeProfileMembershipPlan, + title: String, + month_price_cents: u64, + year_price_cents: u64, + period_points: u64, + model_access: RuntimeProfileMembershipModelAccess, + concurrent_job_limit: u32, + enabled: bool, + sort_order: i32, + updated_at_micros: i64, +) -> Result { + let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; + let title = + normalize_required_string(title).ok_or(RuntimeProfileFieldError::MissingProductTitle)?; + + if !plan.is_purchasable() { + return Ok(RuntimeProfileMembershipPlanAdminUpsertInput { + admin_user_id, + plan, + title, + month_price_cents: 0, + year_price_cents: 0, + period_points: 0, + model_access, + concurrent_job_limit, + enabled: false, + sort_order, + updated_at_micros, + }); + } + + if month_price_cents == 0 || year_price_cents == 0 || concurrent_job_limit == 0 { + return Err(RuntimeProfileFieldError::InvalidMembershipPlan); + } + + Ok(RuntimeProfileMembershipPlanAdminUpsertInput { + admin_user_id, + plan, + title, + month_price_cents, + year_price_cents, + period_points, + model_access, + concurrent_job_limit, + enabled, + sort_order, + updated_at_micros, + }) +} + pub fn build_runtime_profile_recharge_order_paid_input( order_id: String, paid_at_micros: i64, diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 973adad40..6f48a750d 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1698,6 +1698,30 @@ pub struct RuntimeProfileRechargeOrderAdminEntrySnapshot { pub refunds: Vec, pub active_hold: Option, pub wallet: RuntimeProfileAdminWalletSnapshot, + /// 会员购买 / 升级的权益变更快照;人工复核退款时据此还原变更前后的档位、周期、补点与有效期。 + pub membership_change: Option, +} + +/// 会员订单的权益变更快照(只读投影,字段与 `profile_recharge_order` 的末位快照列一一对应)。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipOrderChangeSnapshot { + /// `purchase` 或 `upgrade`。 + pub change_kind: String, + pub plan_before: Option, + pub plan_after: Option, + pub cycle_kind_before: Option, + pub cycle_kind_after: Option, + pub period_points_before: Option, + pub period_points_after: Option, + pub granted_points_delta: i64, + pub cycle_index: Option, + pub expires_at_before_micros: Option, + pub expires_at_after_micros: Option, + pub cycle_resets_at_before_micros: Option, + pub cycle_resets_at_after_micros: Option, + /// 订单价格拆分 JSON(目标档位价、当前档位价、实付金额),仅用于人工核对。 + pub price_breakdown_json: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -1780,6 +1804,48 @@ pub struct RuntimeProfileRechargeProductAdminProcedureResult { pub error_message: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipPlanAdminListInput { + pub admin_user_id: String, +} + +/// 后台会员档位改价 / 改权益入参。 +/// +/// `rank` 不在入参里:升级比较基准由代码内的档位目录决定,后台不能改。 +/// `title` 与 `sort_order` 仍可编辑,`normal` 档位的价格与额度会被忽略(它不可购买)。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipPlanAdminUpsertInput { + pub admin_user_id: String, + pub plan: RuntimeProfileMembershipPlan, + pub title: String, + pub month_price_cents: u64, + pub year_price_cents: u64, + pub period_points: u64, + pub model_access: RuntimeProfileMembershipModelAccess, + pub concurrent_job_limit: u32, + pub enabled: bool, + pub sort_order: i32, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipPlanAdminListProcedureResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipPlanAdminProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileRechargeCenterGetInput { diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index e8f1f0457..f282bd0c4 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -91,6 +91,7 @@ pub enum RuntimeProfileFieldError { InvalidRechargeProductDuration, InvalidRechargeProductKind, InvalidRechargeProductTier, + InvalidMembershipPlan, MissingPaymentChannel, #[cfg(any())] MissingWorldKey, @@ -186,6 +187,9 @@ impl std::fmt::Display for RuntimeProfileFieldError { } Self::InvalidRechargeProductKind => f.write_str("充值商品类型无效"), Self::InvalidRechargeProductTier => f.write_str("会员商品 tier 无效"), + Self::InvalidMembershipPlan => { + f.write_str("会员档位配置无效:月价、年价、每期泥点与并发上限必须有效") + } Self::MissingPaymentChannel => f.write_str("recharge.payment_channel 不能为空"), #[cfg(any())] Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"), diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index b0ee057ca..86921c9ca 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -87,6 +87,8 @@ use module_runtime::{ build_runtime_profile_feedback_submission_record, build_runtime_profile_invite_code_admin_list_input, build_runtime_profile_invite_code_admin_upsert_input, build_runtime_profile_invite_code_record, + build_runtime_profile_membership_plan_admin_list_input, + build_runtime_profile_membership_plan_admin_upsert_input, build_runtime_profile_recharge_center_get_input, build_runtime_profile_recharge_center_record, build_runtime_profile_recharge_order_close_input, build_runtime_profile_recharge_order_create_input, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs index b92650e66..b61af2978 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs @@ -563,11 +563,6 @@ impl From duration_days: input.duration_days, badge_label: input.badge_label, description: input.description, - tier: map_runtime_profile_membership_tier(input.tier), - membership_period_points: input.membership_period_points, - membership_period_days: input.membership_period_days, - membership_queue_limit: input.membership_queue_limit, - membership_discount_bps: input.membership_discount_bps, enabled: input.enabled, sort_order: input.sort_order, updated_at_micros: input.updated_at_micros, @@ -1381,11 +1376,6 @@ pub(crate) fn map_runtime_profile_recharge_product_snapshot( duration_days: snapshot.duration_days, badge_label: snapshot.badge_label, description: snapshot.description, - tier: map_runtime_profile_membership_tier_back(snapshot.tier), - membership_period_points: snapshot.membership_period_points, - membership_period_days: snapshot.membership_period_days, - membership_queue_limit: snapshot.membership_queue_limit, - membership_discount_bps: snapshot.membership_discount_bps, } } @@ -1401,15 +1391,10 @@ pub(crate) fn map_runtime_profile_recharge_center_snapshot( .into_iter() .map(map_runtime_profile_recharge_product_snapshot) .collect(), - membership_products: snapshot - .membership_products + membership_plans: snapshot + .membership_plans .into_iter() - .map(map_runtime_profile_recharge_product_snapshot) - .collect(), - benefits: snapshot - .benefits - .into_iter() - .map(map_runtime_profile_membership_benefit_snapshot) + .map(map_runtime_profile_membership_plan_record) .collect(), latest_order: snapshot .latest_order @@ -1434,11 +1419,6 @@ pub(crate) fn map_runtime_profile_recharge_product_config_snapshot( duration_days: snapshot.duration_days, badge_label: snapshot.badge_label, description: snapshot.description, - tier: map_runtime_profile_membership_tier_back(snapshot.tier), - membership_period_points: snapshot.membership_period_points, - membership_period_days: snapshot.membership_period_days, - membership_queue_limit: snapshot.membership_queue_limit, - membership_discount_bps: snapshot.membership_discount_bps, enabled: snapshot.enabled, sort_order: snapshot.sort_order, created_by: snapshot.created_by, @@ -1448,19 +1428,20 @@ pub(crate) fn map_runtime_profile_recharge_product_config_snapshot( } } -pub(crate) fn map_runtime_profile_membership_benefit_snapshot( - snapshot: RuntimeProfileMembershipBenefitSnapshot, -) -> module_runtime::RuntimeProfileMembershipBenefitSnapshot { - module_runtime::RuntimeProfileMembershipBenefitSnapshot { - benefit_name: snapshot.benefit_name, - normal_value: snapshot.normal_value, - month_value: snapshot.month_value, - season_value: snapshot.season_value, - year_value: snapshot.year_value, - starter_value: snapshot.starter_value, - basic_value: snapshot.basic_value, - pro_value: snapshot.pro_value, - ultimate_value: snapshot.ultimate_value, +pub(crate) fn map_runtime_profile_membership_plan_record( + record: RuntimeProfileMembershipPlanRecord, +) -> module_runtime::RuntimeProfileMembershipPlanRecord { + module_runtime::RuntimeProfileMembershipPlanRecord { + plan: map_runtime_profile_membership_plan_back(record.plan), + title: record.title, + rank: record.rank, + month_price_cents: record.month_price_cents, + year_price_cents: record.year_price_cents, + period_points: record.period_points, + model_access: map_runtime_profile_membership_model_access_back(record.model_access), + concurrent_job_limit: record.concurrent_job_limit, + sort_order: record.sort_order, + enabled: record.enabled, } } @@ -1470,7 +1451,7 @@ pub(crate) fn map_runtime_profile_membership_snapshot( module_runtime::RuntimeProfileMembershipSnapshot { user_id: snapshot.user_id, status: map_runtime_profile_membership_status_back(snapshot.status), - tier: map_runtime_profile_membership_tier_back(snapshot.tier), + plan: map_runtime_profile_membership_plan_back(snapshot.plan), started_at_micros: snapshot.started_at_micros, expires_at_micros: snapshot.expires_at_micros, updated_at_micros: snapshot.updated_at_micros, @@ -1478,7 +1459,9 @@ pub(crate) fn map_runtime_profile_membership_snapshot( cycle_resets_at_micros: snapshot.cycle_resets_at_micros, cycle_granted_points: snapshot.cycle_granted_points, cycle_remaining_points: snapshot.cycle_remaining_points, - cycle_period_days: snapshot.cycle_period_days, + cycle_index: snapshot.cycle_index, + cycle_count: snapshot.cycle_count, + cycle_kind: map_runtime_profile_membership_cycle_kind_back(snapshot.cycle_kind), } } @@ -1635,6 +1618,9 @@ pub(crate) fn map_runtime_profile_recharge_order_admin_entry_snapshot( .active_hold .map(map_runtime_profile_recharge_refund_hold_snapshot), wallet: map_runtime_profile_admin_wallet_snapshot(snapshot.wallet), + membership_change: snapshot + .membership_change + .map(map_runtime_profile_membership_order_change_snapshot), } } @@ -1956,37 +1942,168 @@ pub(crate) fn map_runtime_profile_recharge_product_kind_back( } } -pub(crate) fn map_runtime_profile_membership_tier( - value: module_runtime::RuntimeProfileMembershipTier, -) -> crate::module_bindings::RuntimeProfileMembershipTier { +pub(crate) fn map_runtime_profile_membership_plan( + value: module_runtime::RuntimeProfileMembershipPlan, +) -> crate::module_bindings::RuntimeProfileMembershipPlan { match value { - module_runtime::RuntimeProfileMembershipTier::Normal => { - crate::module_bindings::RuntimeProfileMembershipTier::Normal + module_runtime::RuntimeProfileMembershipPlan::Normal => { + crate::module_bindings::RuntimeProfileMembershipPlan::Normal } - module_runtime::RuntimeProfileMembershipTier::Month => { - crate::module_bindings::RuntimeProfileMembershipTier::Month + module_runtime::RuntimeProfileMembershipPlan::Starter => { + crate::module_bindings::RuntimeProfileMembershipPlan::Starter } - module_runtime::RuntimeProfileMembershipTier::Season => { - crate::module_bindings::RuntimeProfileMembershipTier::Season + module_runtime::RuntimeProfileMembershipPlan::Plus => { + crate::module_bindings::RuntimeProfileMembershipPlan::Plus } - module_runtime::RuntimeProfileMembershipTier::Year => { - crate::module_bindings::RuntimeProfileMembershipTier::Year + module_runtime::RuntimeProfileMembershipPlan::Pro => { + crate::module_bindings::RuntimeProfileMembershipPlan::Pro } - module_runtime::RuntimeProfileMembershipTier::Starter => { - crate::module_bindings::RuntimeProfileMembershipTier::Starter - } - module_runtime::RuntimeProfileMembershipTier::Basic => { - crate::module_bindings::RuntimeProfileMembershipTier::Basic - } - module_runtime::RuntimeProfileMembershipTier::Pro => { - crate::module_bindings::RuntimeProfileMembershipTier::Pro - } - module_runtime::RuntimeProfileMembershipTier::Ultimate => { - crate::module_bindings::RuntimeProfileMembershipTier::Ultimate + module_runtime::RuntimeProfileMembershipPlan::Max => { + crate::module_bindings::RuntimeProfileMembershipPlan::Max } } } +pub(crate) fn map_runtime_profile_membership_plan_back( + value: crate::module_bindings::RuntimeProfileMembershipPlan, +) -> module_runtime::RuntimeProfileMembershipPlan { + match value { + crate::module_bindings::RuntimeProfileMembershipPlan::Normal => { + module_runtime::RuntimeProfileMembershipPlan::Normal + } + crate::module_bindings::RuntimeProfileMembershipPlan::Starter => { + module_runtime::RuntimeProfileMembershipPlan::Starter + } + crate::module_bindings::RuntimeProfileMembershipPlan::Plus => { + module_runtime::RuntimeProfileMembershipPlan::Plus + } + crate::module_bindings::RuntimeProfileMembershipPlan::Pro => { + module_runtime::RuntimeProfileMembershipPlan::Pro + } + crate::module_bindings::RuntimeProfileMembershipPlan::Max => { + module_runtime::RuntimeProfileMembershipPlan::Max + } + } +} + +pub(crate) fn map_runtime_profile_membership_cycle_kind_back( + value: crate::module_bindings::RuntimeProfileMembershipCycleKind, +) -> module_runtime::RuntimeProfileMembershipCycleKind { + match value { + crate::module_bindings::RuntimeProfileMembershipCycleKind::Monthly => { + module_runtime::RuntimeProfileMembershipCycleKind::Monthly + } + crate::module_bindings::RuntimeProfileMembershipCycleKind::Yearly => { + module_runtime::RuntimeProfileMembershipCycleKind::Yearly + } + } +} + +pub(crate) fn map_runtime_profile_membership_model_access( + value: module_runtime::RuntimeProfileMembershipModelAccess, +) -> crate::module_bindings::RuntimeProfileMembershipModelAccess { + match value { + module_runtime::RuntimeProfileMembershipModelAccess::Basic => { + crate::module_bindings::RuntimeProfileMembershipModelAccess::Basic + } + module_runtime::RuntimeProfileMembershipModelAccess::Full => { + crate::module_bindings::RuntimeProfileMembershipModelAccess::Full + } + } +} + +pub(crate) fn map_runtime_profile_membership_model_access_back( + value: crate::module_bindings::RuntimeProfileMembershipModelAccess, +) -> module_runtime::RuntimeProfileMembershipModelAccess { + match value { + crate::module_bindings::RuntimeProfileMembershipModelAccess::Basic => { + module_runtime::RuntimeProfileMembershipModelAccess::Basic + } + crate::module_bindings::RuntimeProfileMembershipModelAccess::Full => { + module_runtime::RuntimeProfileMembershipModelAccess::Full + } + } +} + +pub(crate) fn map_runtime_profile_membership_order_change_snapshot( + snapshot: RuntimeProfileMembershipOrderChangeSnapshot, +) -> module_runtime::RuntimeProfileMembershipOrderChangeSnapshot { + module_runtime::RuntimeProfileMembershipOrderChangeSnapshot { + change_kind: snapshot.change_kind, + plan_before: snapshot.plan_before, + plan_after: snapshot.plan_after, + cycle_kind_before: snapshot.cycle_kind_before, + cycle_kind_after: snapshot.cycle_kind_after, + period_points_before: snapshot.period_points_before, + period_points_after: snapshot.period_points_after, + granted_points_delta: snapshot.granted_points_delta, + cycle_index: snapshot.cycle_index, + expires_at_before_micros: snapshot.expires_at_before_micros, + expires_at_after_micros: snapshot.expires_at_after_micros, + cycle_resets_at_before_micros: snapshot.cycle_resets_at_before_micros, + cycle_resets_at_after_micros: snapshot.cycle_resets_at_after_micros, + price_breakdown_json: snapshot.price_breakdown_json, + } +} + +impl From + for RuntimeProfileMembershipPlanAdminListInput +{ + fn from(input: module_runtime::RuntimeProfileMembershipPlanAdminListInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + } + } +} + +impl From + for RuntimeProfileMembershipPlanAdminUpsertInput +{ + fn from(input: module_runtime::RuntimeProfileMembershipPlanAdminUpsertInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + plan: map_runtime_profile_membership_plan(input.plan), + title: input.title, + month_price_cents: input.month_price_cents, + year_price_cents: input.year_price_cents, + period_points: input.period_points, + model_access: map_runtime_profile_membership_model_access(input.model_access), + concurrent_job_limit: input.concurrent_job_limit, + enabled: input.enabled, + sort_order: input.sort_order, + updated_at_micros: input.updated_at_micros, + } + } +} + +pub(crate) fn map_runtime_profile_membership_plan_admin_list_procedure_result( + result: RuntimeProfileMembershipPlanAdminListProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .entries + .into_iter() + .map(map_runtime_profile_membership_plan_record) + .collect()) +} + +pub(crate) fn map_runtime_profile_membership_plan_admin_procedure_result( + result: RuntimeProfileMembershipPlanAdminProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let record = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("membership plan 快照"))?; + + Ok(map_runtime_profile_membership_plan_record(record)) +} + pub(crate) fn map_runtime_profile_membership_status_back( value: crate::module_bindings::RuntimeProfileMembershipStatus, ) -> module_runtime::RuntimeProfileMembershipStatus { @@ -2000,37 +2117,6 @@ pub(crate) fn map_runtime_profile_membership_status_back( } } -pub(crate) fn map_runtime_profile_membership_tier_back( - value: crate::module_bindings::RuntimeProfileMembershipTier, -) -> module_runtime::RuntimeProfileMembershipTier { - match value { - crate::module_bindings::RuntimeProfileMembershipTier::Normal => { - module_runtime::RuntimeProfileMembershipTier::Normal - } - crate::module_bindings::RuntimeProfileMembershipTier::Month => { - module_runtime::RuntimeProfileMembershipTier::Month - } - crate::module_bindings::RuntimeProfileMembershipTier::Season => { - module_runtime::RuntimeProfileMembershipTier::Season - } - crate::module_bindings::RuntimeProfileMembershipTier::Year => { - module_runtime::RuntimeProfileMembershipTier::Year - } - crate::module_bindings::RuntimeProfileMembershipTier::Starter => { - module_runtime::RuntimeProfileMembershipTier::Starter - } - crate::module_bindings::RuntimeProfileMembershipTier::Basic => { - module_runtime::RuntimeProfileMembershipTier::Basic - } - crate::module_bindings::RuntimeProfileMembershipTier::Pro => { - module_runtime::RuntimeProfileMembershipTier::Pro - } - crate::module_bindings::RuntimeProfileMembershipTier::Ultimate => { - module_runtime::RuntimeProfileMembershipTier::Ultimate - } - } -} - pub(crate) fn map_runtime_profile_recharge_order_status_back( value: crate::module_bindings::RuntimeProfileRechargeOrderStatus, ) -> module_runtime::RuntimeProfileRechargeOrderStatus { diff --git a/server-rs/crates/spacetime-client/src/active/runtime.rs b/server-rs/crates/spacetime-client/src/active/runtime.rs index 119393fb1..866d78b5c 100644 --- a/server-rs/crates/spacetime-client/src/active/runtime.rs +++ b/server-rs/crates/spacetime-client/src/active/runtime.rs @@ -1652,11 +1652,6 @@ impl SpacetimeClient { duration_days: u32, badge_label: String, description: String, - tier: module_runtime::RuntimeProfileMembershipTier, - membership_period_points: u64, - membership_period_days: u32, - membership_queue_limit: u32, - membership_discount_bps: u32, enabled: bool, sort_order: i32, updated_at_micros: i64, @@ -1672,11 +1667,6 @@ impl SpacetimeClient { duration_days, badge_label, description, - tier, - membership_period_points, - membership_period_days, - membership_queue_limit, - membership_discount_bps, enabled, sort_order, updated_at_micros, @@ -1705,6 +1695,86 @@ impl SpacetimeClient { .await } + /// 后台读取会员档位目录(价格与权益的唯一真相源)。 + pub async fn admin_list_profile_membership_plans( + &self, + admin_user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = build_runtime_profile_membership_plan_admin_list_input(admin_user_id) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect( + "admin_list_profile_membership_plans", + move |connection, sender| { + connection + .procedures() + .admin_list_profile_membership_plans_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_membership_plan_admin_list_procedure_result, + ); + send_once(&sender, mapped); + }); + }, + ) + .await + } + + /// 后台按档位改会员价格与权益;`rank` 由代码内目录决定,后台不能改。 + #[allow(clippy::too_many_arguments)] + pub async fn admin_upsert_profile_membership_plan( + &self, + admin_user_id: String, + plan: module_runtime::RuntimeProfileMembershipPlan, + title: String, + month_price_cents: u64, + year_price_cents: u64, + period_points: u64, + model_access: module_runtime::RuntimeProfileMembershipModelAccess, + concurrent_job_limit: u32, + enabled: bool, + sort_order: i32, + updated_at_micros: i64, + ) -> Result { + let procedure_input = build_runtime_profile_membership_plan_admin_upsert_input( + admin_user_id, + plan, + title, + month_price_cents, + year_price_cents, + period_points, + model_access, + concurrent_job_limit, + enabled, + sort_order, + updated_at_micros, + ) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect( + "admin_upsert_profile_membership_plan", + move |connection, sender| { + connection + .procedures() + .admin_upsert_profile_membership_plan_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_membership_plan_admin_procedure_result, + ); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn admin_upsert_profile_redeem_code( &self, admin_user_id: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index dfb4367c4..3615b2453 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -38,6 +38,7 @@ pub mod admin_initialize_profile_wallet_consumption_projections_and_return_proce pub mod admin_list_editor_assets_and_return_procedure; pub mod admin_list_editor_showcase_assets_and_return_procedure; pub mod admin_list_profile_invite_codes_procedure; +pub mod admin_list_profile_membership_plans_procedure; pub mod admin_list_profile_recharge_orders_and_return_procedure; pub mod admin_list_profile_recharge_products_procedure; pub mod admin_list_profile_redeem_codes_procedure; @@ -45,6 +46,7 @@ pub mod admin_list_profile_task_configs_procedure; pub mod admin_reconcile_profile_wallet_consumption_and_return_procedure; pub mod admin_review_editor_showcase_asset_and_return_procedure; pub mod admin_upsert_profile_invite_code_procedure; +pub mod admin_upsert_profile_membership_plan_procedure; pub mod admin_upsert_profile_recharge_product_procedure; pub mod admin_upsert_profile_redeem_code_procedure; pub mod admin_upsert_profile_task_config_procedure; @@ -648,12 +650,18 @@ pub mod runtime_profile_invite_code_admin_list_procedure_result_type; pub mod runtime_profile_invite_code_admin_procedure_result_type; pub mod runtime_profile_invite_code_admin_upsert_input_type; pub mod runtime_profile_invite_code_snapshot_type; -pub mod runtime_profile_membership_benefit_snapshot_type; +pub mod runtime_profile_membership_change_kind_type; +pub mod runtime_profile_membership_cycle_kind_type; pub mod runtime_profile_membership_model_access_type; +pub mod runtime_profile_membership_order_change_snapshot_type; +pub mod runtime_profile_membership_plan_admin_list_input_type; +pub mod runtime_profile_membership_plan_admin_list_procedure_result_type; +pub mod runtime_profile_membership_plan_admin_procedure_result_type; +pub mod runtime_profile_membership_plan_admin_upsert_input_type; +pub mod runtime_profile_membership_plan_record_type; pub mod runtime_profile_membership_plan_type; pub mod runtime_profile_membership_snapshot_type; pub mod runtime_profile_membership_status_type; -pub mod runtime_profile_membership_tier_type; pub mod runtime_profile_recharge_center_get_input_type; pub mod runtime_profile_recharge_center_procedure_result_type; pub mod runtime_profile_recharge_center_snapshot_type; @@ -843,6 +851,7 @@ pub use admin_initialize_profile_wallet_consumption_projections_and_return_proce pub use admin_list_editor_assets_and_return_procedure::admin_list_editor_assets_and_return; pub use admin_list_editor_showcase_assets_and_return_procedure::admin_list_editor_showcase_assets_and_return; pub use admin_list_profile_invite_codes_procedure::admin_list_profile_invite_codes; +pub use admin_list_profile_membership_plans_procedure::admin_list_profile_membership_plans; pub use admin_list_profile_recharge_orders_and_return_procedure::admin_list_profile_recharge_orders_and_return; pub use admin_list_profile_recharge_products_procedure::admin_list_profile_recharge_products; pub use admin_list_profile_redeem_codes_procedure::admin_list_profile_redeem_codes; @@ -850,6 +859,7 @@ pub use admin_list_profile_task_configs_procedure::admin_list_profile_task_confi pub use admin_reconcile_profile_wallet_consumption_and_return_procedure::admin_reconcile_profile_wallet_consumption_and_return; pub use admin_review_editor_showcase_asset_and_return_procedure::admin_review_editor_showcase_asset_and_return; pub use admin_upsert_profile_invite_code_procedure::admin_upsert_profile_invite_code; +pub use admin_upsert_profile_membership_plan_procedure::admin_upsert_profile_membership_plan; pub use admin_upsert_profile_recharge_product_procedure::admin_upsert_profile_recharge_product; pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code; pub use admin_upsert_profile_task_config_procedure::admin_upsert_profile_task_config; @@ -1453,12 +1463,18 @@ pub use runtime_profile_invite_code_admin_list_procedure_result_type::RuntimePro pub use runtime_profile_invite_code_admin_procedure_result_type::RuntimeProfileInviteCodeAdminProcedureResult; pub use runtime_profile_invite_code_admin_upsert_input_type::RuntimeProfileInviteCodeAdminUpsertInput; pub use runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot; -pub use runtime_profile_membership_benefit_snapshot_type::RuntimeProfileMembershipBenefitSnapshot; +pub use runtime_profile_membership_change_kind_type::RuntimeProfileMembershipChangeKind; +pub use runtime_profile_membership_cycle_kind_type::RuntimeProfileMembershipCycleKind; pub use runtime_profile_membership_model_access_type::RuntimeProfileMembershipModelAccess; +pub use runtime_profile_membership_order_change_snapshot_type::RuntimeProfileMembershipOrderChangeSnapshot; +pub use runtime_profile_membership_plan_admin_list_input_type::RuntimeProfileMembershipPlanAdminListInput; +pub use runtime_profile_membership_plan_admin_list_procedure_result_type::RuntimeProfileMembershipPlanAdminListProcedureResult; +pub use runtime_profile_membership_plan_admin_procedure_result_type::RuntimeProfileMembershipPlanAdminProcedureResult; +pub use runtime_profile_membership_plan_admin_upsert_input_type::RuntimeProfileMembershipPlanAdminUpsertInput; +pub use runtime_profile_membership_plan_record_type::RuntimeProfileMembershipPlanRecord; pub use runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; pub use runtime_profile_membership_snapshot_type::RuntimeProfileMembershipSnapshot; pub use runtime_profile_membership_status_type::RuntimeProfileMembershipStatus; -pub use runtime_profile_membership_tier_type::RuntimeProfileMembershipTier; pub use runtime_profile_recharge_center_get_input_type::RuntimeProfileRechargeCenterGetInput; pub use runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult; pub use runtime_profile_recharge_center_snapshot_type::RuntimeProfileRechargeCenterSnapshot; @@ -3992,19 +4008,19 @@ impl __sdk::SubscriptionHandle for SubscriptionHandle { /// either a [`DbConnection`] or an [`EventContext`] and operate on either. pub trait RemoteDbContext: __sdk::DbContext< - DbView = RemoteTables, - Reducers = RemoteReducers, - SubscriptionBuilder = __sdk::SubscriptionBuilder, - > + DbView = RemoteTables, + Reducers = RemoteReducers, + SubscriptionBuilder = __sdk::SubscriptionBuilder, +> { } impl< - Ctx: __sdk::DbContext< + Ctx: __sdk::DbContext< DbView = RemoteTables, Reducers = RemoteReducers, SubscriptionBuilder = __sdk::SubscriptionBuilder, >, -> RemoteDbContext for Ctx + > RemoteDbContext for Ctx { } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_membership_plans_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_membership_plans_procedure.rs new file mode 100644 index 000000000..6f1dc3cd1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_membership_plans_procedure.rs @@ -0,0 +1,61 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_membership_plan_admin_list_input_type::RuntimeProfileMembershipPlanAdminListInput; +use super::runtime_profile_membership_plan_admin_list_procedure_result_type::RuntimeProfileMembershipPlanAdminListProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AdminListProfileMembershipPlansArgs { + pub input: RuntimeProfileMembershipPlanAdminListInput, +} + +impl __sdk::InModule for AdminListProfileMembershipPlansArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `admin_list_profile_membership_plans`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait admin_list_profile_membership_plans { + fn admin_list_profile_membership_plans( + &self, + input: RuntimeProfileMembershipPlanAdminListInput, + ) { + self.admin_list_profile_membership_plans_then(input, |_, _| {}); + } + + fn admin_list_profile_membership_plans_then( + &self, + input: RuntimeProfileMembershipPlanAdminListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl admin_list_profile_membership_plans for super::RemoteProcedures { + fn admin_list_profile_membership_plans_then( + &self, + input: RuntimeProfileMembershipPlanAdminListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, RuntimeProfileMembershipPlanAdminListProcedureResult>( + "admin_list_profile_membership_plans", + AdminListProfileMembershipPlansArgs { input, }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_membership_plan_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_membership_plan_procedure.rs new file mode 100644 index 000000000..3ff1b58f7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_membership_plan_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_membership_plan_admin_procedure_result_type::RuntimeProfileMembershipPlanAdminProcedureResult; +use super::runtime_profile_membership_plan_admin_upsert_input_type::RuntimeProfileMembershipPlanAdminUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AdminUpsertProfileMembershipPlanArgs { + pub input: RuntimeProfileMembershipPlanAdminUpsertInput, +} + +impl __sdk::InModule for AdminUpsertProfileMembershipPlanArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `admin_upsert_profile_membership_plan`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait admin_upsert_profile_membership_plan { + fn admin_upsert_profile_membership_plan( + &self, + input: RuntimeProfileMembershipPlanAdminUpsertInput, + ) { + self.admin_upsert_profile_membership_plan_then(input, |_, _| {}); + } + + fn admin_upsert_profile_membership_plan_then( + &self, + input: RuntimeProfileMembershipPlanAdminUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl admin_upsert_profile_membership_plan for super::RemoteProcedures { + fn admin_upsert_profile_membership_plan_then( + &self, + input: RuntimeProfileMembershipPlanAdminUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileMembershipPlanAdminProcedureResult>( + "admin_upsert_profile_membership_plan", + AdminUpsertProfileMembershipPlanArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_admin_game_distribution_user_review_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_admin_game_distribution_user_review_and_return_procedure.rs index fc6340e6e..644d782d8 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_admin_game_distribution_user_review_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_admin_game_distribution_user_review_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait get_admin_game_distribution_user_review_and_return { input: GameDistributionAdminUserReviewInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl get_admin_game_distribution_user_review_and_return for super::RemoteProcedu input: GameDistributionAdminUserReviewInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, GameDistributionAdminUserReviewResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_my_review_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_my_review_and_return_procedure.rs index 5d41df632..035b88be5 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_my_review_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_my_review_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait get_game_distribution_my_review_and_return { input: GameDistributionMyReviewInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_game_distribution_my_review_and_return for super::RemoteProcedures { input: GameDistributionMyReviewInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, GameDistributionUserReviewResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_user_reviews_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_user_reviews_and_return_procedure.rs index 854482048..0b1536fb3 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_user_reviews_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_user_reviews_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait list_admin_game_distribution_user_reviews_and_return { input: GameDistributionAdminUserReviewListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl list_admin_game_distribution_user_reviews_and_return for super::RemoteProce input: GameDistributionAdminUserReviewListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, GameDistributionAdminUserReviewResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_agc_tracking_events_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_agc_tracking_events_procedure.rs index 6244fd337..5daaa8425 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_agc_tracking_events_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_agc_tracking_events_procedure.rs @@ -28,10 +28,10 @@ pub trait list_agc_tracking_events { query_json: String, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, __sdk::InternalError>, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ); } @@ -41,10 +41,10 @@ impl list_agc_tracking_events for super::RemoteProcedures { query_json: String, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, __sdk::InternalError>, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, Result>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_review_games_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_review_games_and_return_procedure.rs index 7843651e5..2d2b893c0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_review_games_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_review_games_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait list_game_distribution_review_games_and_return { input: GameDistributionReviewGameListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl list_game_distribution_review_games_and_return for super::RemoteProcedures input: GameDistributionReviewGameListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, GameDistributionAdminUserReviewResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_user_reviews_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_user_reviews_and_return_procedure.rs index 253592e7e..1abc81320 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_user_reviews_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_user_reviews_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait list_game_distribution_user_reviews_and_return { input: GameDistributionUserReviewListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl list_game_distribution_user_reviews_and_return for super::RemoteProcedures input: GameDistributionUserReviewListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, GameDistributionUserReviewResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/moderate_game_distribution_user_review_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/moderate_game_distribution_user_review_and_return_procedure.rs index 57af37e0d..071918ac1 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/moderate_game_distribution_user_review_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/moderate_game_distribution_user_review_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait moderate_game_distribution_user_review_and_return { input: GameDistributionReviewModerationInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl moderate_game_distribution_user_review_and_return for super::RemoteProcedur input: GameDistributionReviewModerationInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, GameDistributionAdminUserReviewResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_table.rs index a06543e88..b1b70a4db 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_table.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_table.rs @@ -3,8 +3,9 @@ #![allow(unused, clippy::all)] use super::profile_membership_type::ProfileMembership; +use super::runtime_profile_membership_cycle_kind_type::RuntimeProfileMembershipCycleKind; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; use super::runtime_profile_membership_status_type::RuntimeProfileMembershipStatus; -use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier; use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; /// Table handle for the table `profile_membership`. diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_type.rs index 8d1757038..01c8e27d6 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_type.rs @@ -4,15 +4,15 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_membership_cycle_kind_type::RuntimeProfileMembershipCycleKind; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; use super::runtime_profile_membership_status_type::RuntimeProfileMembershipStatus; -use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct ProfileMembership { pub user_id: String, pub status: RuntimeProfileMembershipStatus, - pub tier: RuntimeProfileMembershipTier, pub started_at: __sdk::Timestamp, pub expires_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, @@ -20,7 +20,10 @@ pub struct ProfileMembership { pub cycle_resets_at: Option<__sdk::Timestamp>, pub cycle_granted_points: u64, pub cycle_remaining_points: u64, - pub cycle_period_days: u32, + pub plan: RuntimeProfileMembershipPlan, + pub cycle_index: u32, + pub cycle_count: u32, + pub cycle_kind: RuntimeProfileMembershipCycleKind, } impl __sdk::InModule for ProfileMembership { @@ -33,7 +36,6 @@ impl __sdk::InModule for ProfileMembership { pub struct ProfileMembershipCols { pub user_id: __sdk::__query_builder::Col, pub status: __sdk::__query_builder::Col, - pub tier: __sdk::__query_builder::Col, pub started_at: __sdk::__query_builder::Col, pub expires_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, @@ -41,7 +43,11 @@ pub struct ProfileMembershipCols { pub cycle_resets_at: __sdk::__query_builder::Col>, pub cycle_granted_points: __sdk::__query_builder::Col, pub cycle_remaining_points: __sdk::__query_builder::Col, - pub cycle_period_days: __sdk::__query_builder::Col, + pub plan: __sdk::__query_builder::Col, + pub cycle_index: __sdk::__query_builder::Col, + pub cycle_count: __sdk::__query_builder::Col, + pub cycle_kind: + __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for ProfileMembership { @@ -50,7 +56,6 @@ impl __sdk::__query_builder::HasCols for ProfileMembership { ProfileMembershipCols { user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), status: __sdk::__query_builder::Col::new(table_name, "status"), - tier: __sdk::__query_builder::Col::new(table_name, "tier"), started_at: __sdk::__query_builder::Col::new(table_name, "started_at"), expires_at: __sdk::__query_builder::Col::new(table_name, "expires_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), @@ -64,7 +69,10 @@ impl __sdk::__query_builder::HasCols for ProfileMembership { table_name, "cycle_remaining_points", ), - cycle_period_days: __sdk::__query_builder::Col::new(table_name, "cycle_period_days"), + plan: __sdk::__query_builder::Col::new(table_name, "plan"), + cycle_index: __sdk::__query_builder::Col::new(table_name, "cycle_index"), + cycle_count: __sdk::__query_builder::Col::new(table_name, "cycle_count"), + cycle_kind: __sdk::__query_builder::Col::new(table_name, "cycle_kind"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_table.rs index 3869911a9..ae9030d06 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_table.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_table.rs @@ -3,6 +3,9 @@ #![allow(unused, clippy::all)] use super::profile_recharge_order_type::ProfileRechargeOrder; +use super::runtime_profile_membership_change_kind_type::RuntimeProfileMembershipChangeKind; +use super::runtime_profile_membership_cycle_kind_type::RuntimeProfileMembershipCycleKind; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; use super::runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus; use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_type.rs index f12c3f8d0..573be0d91 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_type.rs @@ -4,6 +4,9 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_membership_change_kind_type::RuntimeProfileMembershipChangeKind; +use super::runtime_profile_membership_cycle_kind_type::RuntimeProfileMembershipCycleKind; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; use super::runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus; use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; @@ -27,6 +30,20 @@ pub struct ProfileRechargeOrder { pub expiration_checked_at: Option<__sdk::Timestamp>, pub expiration_provider_state: Option, pub expiration_last_error: Option, + pub membership_change_kind: Option, + pub membership_plan_before: Option, + pub membership_plan_after: Option, + pub membership_cycle_kind_before: Option, + pub membership_cycle_kind_after: Option, + pub membership_period_points_before: Option, + pub membership_period_points_after: Option, + pub membership_granted_points_delta: i64, + pub membership_cycle_index: Option, + pub membership_expires_at_before: Option<__sdk::Timestamp>, + pub membership_expires_at_after: Option<__sdk::Timestamp>, + pub membership_cycle_resets_at_before: Option<__sdk::Timestamp>, + pub membership_cycle_resets_at_after: Option<__sdk::Timestamp>, + pub membership_price_breakdown_json: Option, } impl __sdk::InModule for ProfileRechargeOrder { @@ -58,6 +75,38 @@ pub struct ProfileRechargeOrderCols { pub expiration_provider_state: __sdk::__query_builder::Col>, pub expiration_last_error: __sdk::__query_builder::Col>, + pub membership_change_kind: __sdk::__query_builder::Col< + ProfileRechargeOrder, + Option, + >, + pub membership_plan_before: + __sdk::__query_builder::Col>, + pub membership_plan_after: + __sdk::__query_builder::Col>, + pub membership_cycle_kind_before: __sdk::__query_builder::Col< + ProfileRechargeOrder, + Option, + >, + pub membership_cycle_kind_after: __sdk::__query_builder::Col< + ProfileRechargeOrder, + Option, + >, + pub membership_period_points_before: + __sdk::__query_builder::Col>, + pub membership_period_points_after: + __sdk::__query_builder::Col>, + pub membership_granted_points_delta: __sdk::__query_builder::Col, + pub membership_cycle_index: __sdk::__query_builder::Col>, + pub membership_expires_at_before: + __sdk::__query_builder::Col>, + pub membership_expires_at_after: + __sdk::__query_builder::Col>, + pub membership_cycle_resets_at_before: + __sdk::__query_builder::Col>, + pub membership_cycle_resets_at_after: + __sdk::__query_builder::Col>, + pub membership_price_breakdown_json: + __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for ProfileRechargeOrder { @@ -96,6 +145,62 @@ impl __sdk::__query_builder::HasCols for ProfileRechargeOrder { table_name, "expiration_last_error", ), + membership_change_kind: __sdk::__query_builder::Col::new( + table_name, + "membership_change_kind", + ), + membership_plan_before: __sdk::__query_builder::Col::new( + table_name, + "membership_plan_before", + ), + membership_plan_after: __sdk::__query_builder::Col::new( + table_name, + "membership_plan_after", + ), + membership_cycle_kind_before: __sdk::__query_builder::Col::new( + table_name, + "membership_cycle_kind_before", + ), + membership_cycle_kind_after: __sdk::__query_builder::Col::new( + table_name, + "membership_cycle_kind_after", + ), + membership_period_points_before: __sdk::__query_builder::Col::new( + table_name, + "membership_period_points_before", + ), + membership_period_points_after: __sdk::__query_builder::Col::new( + table_name, + "membership_period_points_after", + ), + membership_granted_points_delta: __sdk::__query_builder::Col::new( + table_name, + "membership_granted_points_delta", + ), + membership_cycle_index: __sdk::__query_builder::Col::new( + table_name, + "membership_cycle_index", + ), + membership_expires_at_before: __sdk::__query_builder::Col::new( + table_name, + "membership_expires_at_before", + ), + membership_expires_at_after: __sdk::__query_builder::Col::new( + table_name, + "membership_expires_at_after", + ), + membership_cycle_resets_at_before: __sdk::__query_builder::Col::new( + table_name, + "membership_cycle_resets_at_before", + ), + membership_cycle_resets_at_after: __sdk::__query_builder::Col::new( + table_name, + "membership_cycle_resets_at_after", + ), + membership_price_breakdown_json: __sdk::__query_builder::Col::new( + table_name, + "membership_price_breakdown_json", + ), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_product_config_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_product_config_table.rs index cf0d11d37..d5a19ee07 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_product_config_table.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_product_config_table.rs @@ -3,7 +3,6 @@ #![allow(unused, clippy::all)] use super::profile_recharge_product_config_type::ProfileRechargeProductConfig; -use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier; use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_product_config_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_product_config_type.rs index e25730490..e3c80e159 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_product_config_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_product_config_type.rs @@ -4,7 +4,6 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; -use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier; use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -19,17 +18,12 @@ pub struct ProfileRechargeProductConfig { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: RuntimeProfileMembershipTier, pub enabled: bool, pub sort_order: i32, pub created_by: String, pub created_at: __sdk::Timestamp, pub updated_by: String, pub updated_at: __sdk::Timestamp, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, } impl __sdk::InModule for ProfileRechargeProductConfig { @@ -52,18 +46,12 @@ pub struct ProfileRechargeProductConfigCols { pub duration_days: __sdk::__query_builder::Col, pub badge_label: __sdk::__query_builder::Col, pub description: __sdk::__query_builder::Col, - pub tier: - __sdk::__query_builder::Col, pub enabled: __sdk::__query_builder::Col, pub sort_order: __sdk::__query_builder::Col, pub created_by: __sdk::__query_builder::Col, pub created_at: __sdk::__query_builder::Col, pub updated_by: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, - pub membership_period_points: __sdk::__query_builder::Col, - pub membership_period_days: __sdk::__query_builder::Col, - pub membership_queue_limit: __sdk::__query_builder::Col, - pub membership_discount_bps: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for ProfileRechargeProductConfig { @@ -79,29 +67,12 @@ impl __sdk::__query_builder::HasCols for ProfileRechargeProductConfig { duration_days: __sdk::__query_builder::Col::new(table_name, "duration_days"), badge_label: __sdk::__query_builder::Col::new(table_name, "badge_label"), description: __sdk::__query_builder::Col::new(table_name, "description"), - tier: __sdk::__query_builder::Col::new(table_name, "tier"), enabled: __sdk::__query_builder::Col::new(table_name, "enabled"), sort_order: __sdk::__query_builder::Col::new(table_name, "sort_order"), created_by: __sdk::__query_builder::Col::new(table_name, "created_by"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), updated_by: __sdk::__query_builder::Col::new(table_name, "updated_by"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), - membership_period_points: __sdk::__query_builder::Col::new( - table_name, - "membership_period_points", - ), - membership_period_days: __sdk::__query_builder::Col::new( - table_name, - "membership_period_days", - ), - membership_queue_limit: __sdk::__query_builder::Col::new( - table_name, - "membership_queue_limit", - ), - membership_discount_bps: __sdk::__query_builder::Col::new( - table_name, - "membership_discount_bps", - ), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_tier_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_change_kind_type.rs similarity index 68% rename from server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_tier_type.rs rename to server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_change_kind_type.rs index f8c9e0748..5c6d03e00 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_tier_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_change_kind_type.rs @@ -7,24 +7,12 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] #[derive(Copy, Eq, Hash)] -pub enum RuntimeProfileMembershipTier { - Normal, +pub enum RuntimeProfileMembershipChangeKind { + Purchase, - Month, - - Season, - - Year, - - Starter, - - Basic, - - Pro, - - Ultimate, + Upgrade, } -impl __sdk::InModule for RuntimeProfileMembershipTier { +impl __sdk::InModule for RuntimeProfileMembershipChangeKind { type Module = super::RemoteModule; } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_benefit_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_cycle_kind_type.rs similarity index 50% rename from server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_benefit_snapshot_type.rs rename to server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_cycle_kind_type.rs index 675038d3f..7de7247b1 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_benefit_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_cycle_kind_type.rs @@ -6,18 +6,13 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] -pub struct RuntimeProfileMembershipBenefitSnapshot { - pub benefit_name: String, - pub normal_value: String, - pub month_value: String, - pub season_value: String, - pub year_value: String, - pub starter_value: String, - pub basic_value: String, - pub pro_value: String, - pub ultimate_value: String, +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileMembershipCycleKind { + Monthly, + + Yearly, } -impl __sdk::InModule for RuntimeProfileMembershipBenefitSnapshot { +impl __sdk::InModule for RuntimeProfileMembershipCycleKind { type Module = super::RemoteModule; } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_order_change_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_order_change_snapshot_type.rs new file mode 100644 index 000000000..bbef8b8d4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_order_change_snapshot_type.rs @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipOrderChangeSnapshot { + pub change_kind: String, + pub plan_before: Option, + pub plan_after: Option, + pub cycle_kind_before: Option, + pub cycle_kind_after: Option, + pub period_points_before: Option, + pub period_points_after: Option, + pub granted_points_delta: i64, + pub cycle_index: Option, + pub expires_at_before_micros: Option, + pub expires_at_after_micros: Option, + pub cycle_resets_at_before_micros: Option, + pub cycle_resets_at_after_micros: Option, + pub price_breakdown_json: Option, +} + +impl __sdk::InModule for RuntimeProfileMembershipOrderChangeSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_list_input_type.rs new file mode 100644 index 000000000..9727106a1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_list_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipPlanAdminListInput { + pub admin_user_id: String, +} + +impl __sdk::InModule for RuntimeProfileMembershipPlanAdminListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_list_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_list_procedure_result_type.rs new file mode 100644 index 000000000..52c8aa417 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_list_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_membership_plan_record_type::RuntimeProfileMembershipPlanRecord; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipPlanAdminListProcedureResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileMembershipPlanAdminListProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_procedure_result_type.rs new file mode 100644 index 000000000..78ee5407e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_membership_plan_record_type::RuntimeProfileMembershipPlanRecord; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipPlanAdminProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileMembershipPlanAdminProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_upsert_input_type.rs new file mode 100644 index 000000000..9e38b8171 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_upsert_input_type.rs @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_membership_model_access_type::RuntimeProfileMembershipModelAccess; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipPlanAdminUpsertInput { + pub admin_user_id: String, + pub plan: RuntimeProfileMembershipPlan, + pub title: String, + pub month_price_cents: u64, + pub year_price_cents: u64, + pub period_points: u64, + pub model_access: RuntimeProfileMembershipModelAccess, + pub concurrent_job_limit: u32, + pub enabled: bool, + pub sort_order: i32, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileMembershipPlanAdminUpsertInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_record_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_record_type.rs new file mode 100644 index 000000000..73361a190 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_record_type.rs @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_membership_model_access_type::RuntimeProfileMembershipModelAccess; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipPlanRecord { + 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, +} + +impl __sdk::InModule for RuntimeProfileMembershipPlanRecord { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_snapshot_type.rs index aff6da4ef..87eecbec4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_snapshot_type.rs @@ -4,15 +4,16 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_membership_cycle_kind_type::RuntimeProfileMembershipCycleKind; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; use super::runtime_profile_membership_status_type::RuntimeProfileMembershipStatus; -use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct RuntimeProfileMembershipSnapshot { pub user_id: String, pub status: RuntimeProfileMembershipStatus, - pub tier: RuntimeProfileMembershipTier, + pub plan: RuntimeProfileMembershipPlan, pub started_at_micros: Option, pub expires_at_micros: Option, pub updated_at_micros: Option, @@ -20,7 +21,9 @@ pub struct RuntimeProfileMembershipSnapshot { pub cycle_resets_at_micros: Option, pub cycle_granted_points: u64, pub cycle_remaining_points: u64, - pub cycle_period_days: u32, + pub cycle_index: u32, + pub cycle_count: u32, + pub cycle_kind: RuntimeProfileMembershipCycleKind, } impl __sdk::InModule for RuntimeProfileMembershipSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_snapshot_type.rs index 747739cc1..504e1269d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_snapshot_type.rs @@ -5,7 +5,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; use super::runtime_profile_daily_free_points_snapshot_type::RuntimeProfileDailyFreePointsSnapshot; -use super::runtime_profile_membership_benefit_snapshot_type::RuntimeProfileMembershipBenefitSnapshot; +use super::runtime_profile_membership_plan_record_type::RuntimeProfileMembershipPlanRecord; use super::runtime_profile_membership_snapshot_type::RuntimeProfileMembershipSnapshot; use super::runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot; use super::runtime_profile_recharge_product_snapshot_type::RuntimeProfileRechargeProductSnapshot; @@ -17,8 +17,7 @@ pub struct RuntimeProfileRechargeCenterSnapshot { pub wallet_balance: u64, pub membership: RuntimeProfileMembershipSnapshot, pub point_products: Vec, - pub membership_products: Vec, - pub benefits: Vec, + pub membership_plans: Vec, pub latest_order: Option, pub has_points_recharged: bool, pub daily_free_points: RuntimeProfileDailyFreePointsSnapshot, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_admin_entry_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_admin_entry_snapshot_type.rs index dcc036214..d5a26cf9a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_admin_entry_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_admin_entry_snapshot_type.rs @@ -5,6 +5,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; use super::runtime_profile_admin_wallet_snapshot_type::RuntimeProfileAdminWalletSnapshot; +use super::runtime_profile_membership_order_change_snapshot_type::RuntimeProfileMembershipOrderChangeSnapshot; use super::runtime_profile_recharge_order_refund_settlement_snapshot_type::RuntimeProfileRechargeOrderRefundSettlementSnapshot; use super::runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot; use super::runtime_profile_recharge_refund_hold_snapshot_type::RuntimeProfileRechargeRefundHoldSnapshot; @@ -18,6 +19,7 @@ pub struct RuntimeProfileRechargeOrderAdminEntrySnapshot { pub refunds: Vec, pub active_hold: Option, pub wallet: RuntimeProfileAdminWalletSnapshot, + pub membership_change: Option, } impl __sdk::InModule for RuntimeProfileRechargeOrderAdminEntrySnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_upsert_input_type.rs index 6713a169b..a3babfb24 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_upsert_input_type.rs @@ -4,7 +4,6 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; -use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier; use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -20,11 +19,6 @@ pub struct RuntimeProfileRechargeProductAdminUpsertInput { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: RuntimeProfileMembershipTier, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, pub enabled: bool, pub sort_order: i32, pub updated_at_micros: i64, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_config_snapshot_type.rs index 04654580f..44904194e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_config_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_config_snapshot_type.rs @@ -4,7 +4,6 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; -use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier; use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -19,11 +18,6 @@ pub struct RuntimeProfileRechargeProductConfigSnapshot { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: RuntimeProfileMembershipTier, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, pub enabled: bool, pub sort_order: i32, pub created_by: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_snapshot_type.rs index 9b70f76cd..9a2bd0234 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_snapshot_type.rs @@ -4,7 +4,6 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; -use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier; use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -19,11 +18,6 @@ pub struct RuntimeProfileRechargeProductSnapshot { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: RuntimeProfileMembershipTier, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, } impl __sdk::InModule for RuntimeProfileRechargeProductSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/save_game_distribution_my_review_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/save_game_distribution_my_review_and_return_procedure.rs index 5e331c484..271415f65 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/save_game_distribution_my_review_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/save_game_distribution_my_review_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait save_game_distribution_my_review_and_return { input: GameDistributionUserReviewInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl save_game_distribution_my_review_and_return for super::RemoteProcedures { input: GameDistributionUserReviewInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, GameDistributionUserReviewResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upload_agc_analytics_batch_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/upload_agc_analytics_batch_procedure.rs index c6fb47968..cc583dde6 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/upload_agc_analytics_batch_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/upload_agc_analytics_batch_procedure.rs @@ -28,10 +28,10 @@ pub trait upload_agc_analytics_batch { payload_json: String, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, __sdk::InternalError>, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ); } @@ -41,10 +41,10 @@ impl upload_agc_analytics_batch for super::RemoteProcedures { payload_json: String, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, __sdk::InternalError>, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, Result>( 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 d2f9939e4..e6da80f97 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -1153,6 +1153,45 @@ pub fn admin_upsert_profile_recharge_product( } } +// 会员档位目录的后台读写:后台按档位直接改价与改权益,充值中心与下单重算都以这张表为准。 +#[spacetimedb::procedure] +pub fn admin_list_profile_membership_plans( + ctx: &mut ProcedureContext, + input: RuntimeProfileMembershipPlanAdminListInput, +) -> RuntimeProfileMembershipPlanAdminListProcedureResult { + match ctx.try_with_tx(|tx| list_profile_membership_plan_records(tx, input.clone())) { + Ok(entries) => RuntimeProfileMembershipPlanAdminListProcedureResult { + ok: true, + entries, + error_message: None, + }, + Err(message) => RuntimeProfileMembershipPlanAdminListProcedureResult { + ok: false, + entries: Vec::new(), + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn admin_upsert_profile_membership_plan( + ctx: &mut ProcedureContext, + input: RuntimeProfileMembershipPlanAdminUpsertInput, +) -> RuntimeProfileMembershipPlanAdminProcedureResult { + match ctx.try_with_tx(|tx| upsert_profile_membership_plan_record(tx, input.clone())) { + Ok(record) => RuntimeProfileMembershipPlanAdminProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileMembershipPlanAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + // 新用户注册赠送由后端注册链路调用;流水 ID 固定,保证重试不重复发放。 #[spacetimedb::procedure] pub fn grant_new_user_registration_wallet_reward( @@ -3281,6 +3320,20 @@ mod tests { expiration_checked_at: None, expiration_provider_state: None, expiration_last_error: None, + membership_change_kind: None, + membership_plan_before: None, + membership_plan_after: None, + membership_cycle_kind_before: None, + membership_cycle_kind_after: None, + membership_period_points_before: None, + membership_period_points_after: None, + membership_granted_points_delta: 0, + membership_cycle_index: None, + membership_expires_at_before: None, + membership_expires_at_after: None, + membership_cycle_resets_at_before: None, + membership_cycle_resets_at_after: None, + membership_price_breakdown_json: None, } } @@ -3611,24 +3664,26 @@ mod tests { } #[test] - fn point_recharge_display_is_resolved_per_product() { + fn point_recharge_display_has_no_first_charge_bonus_anywhere() { let products = runtime_profile_recharge_point_products(); - let repeated = resolve_profile_recharge_product_display(products[0].clone(), true); - let untouched = resolve_profile_recharge_product_display(products[1].clone(), false); - - assert_eq!(repeated.product_id, "points_60"); - assert_eq!(repeated.bonus_points, 0); - assert_eq!(repeated.badge_label, ""); - assert_eq!(repeated.description, "60泥点"); - assert_eq!(untouched.product_id, "points_180"); - assert_eq!(untouched.bonus_points, 90); - assert_eq!(untouched.badge_label, "首充加赠"); - assert_eq!(untouched.description, "首充加赠90泥点"); + assert_eq!(products.len(), 6); + for product in &products { + assert_eq!(product.bonus_points, 0, "六档充点商品都没有首充赠送"); + assert_eq!(product.badge_label, ""); + assert_eq!(product.price_cents, product.points_amount * 10); + let displayed = resolve_profile_recharge_product_display(product.clone(), true); + assert_eq!(displayed.bonus_points, 0); + assert_eq!(displayed.badge_label, ""); + assert_eq!(displayed.description, product.title); + } + assert_eq!(products[0].product_id, "points_60"); + assert_eq!(products[5].product_id, "points_3280"); } #[test] - fn legacy_default_recharge_products_migrate_without_overwriting_admin_config() { + fn legacy_default_recharge_products_migrate_without_first_charge_bonus() { let timestamp = Timestamp::from_micros_since_unix_epoch(1_000_000); + // 旧播种行:面额赠送 +「首充双倍」标签 +「首充送N泥点」说明。 let build_row = |points_amount: u64| ProfileRechargeProductConfig { product_id: format!("points_{points_amount}"), title: format!("{points_amount}泥点"), @@ -3639,41 +3694,44 @@ mod tests { duration_days: 0, badge_label: "首充双倍".to_string(), description: format!("首充送{points_amount}泥点"), - tier: RuntimeProfileMembershipTier::Normal, enabled: true, sort_order: 0, created_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(), created_at: timestamp, updated_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(), updated_at: timestamp, - membership_period_points: 0, - membership_period_days: 0, - membership_queue_limit: 0, - membership_discount_bps: 0, }; - assert_eq!( - resolve_default_point_product_migration(&build_row(180)), - Some(DefaultPointProductMigration { - bonus_points: 90, - badge_label: "首充加赠", - description: "首充加赠90泥点", - enabled: true, - }), - ); - assert_eq!( - resolve_default_point_product_migration(&build_row(1_280)), - Some(DefaultPointProductMigration { - bonus_points: 1_280, - badge_label: "首充双倍", - description: "", - enabled: false, - }), - ); + let migration = + resolve_default_point_product_migration(&build_row(180)).expect("旧播种行应被迁移"); + assert_eq!(migration.bonus_points, 0); + assert_eq!(migration.badge_label, ""); + assert_eq!(migration.description, "180泥点"); + assert!(migration.enabled); + // 中文注释:¥128 / ¥328 两档旧版本是停用的首充行,现在改为在售且无赠送。 + let disabled_seed = ProfileRechargeProductConfig { + bonus_points: 0, + badge_label: "首充双倍".to_string(), + enabled: false, + ..build_row(1_280) + }; + let migration = resolve_default_point_product_migration(&disabled_seed) + .expect("停用的 ¥128 档应被启用"); + assert!(migration.enabled); + assert_eq!(migration.bonus_points, 0); + + // 中文注释:后台改过的行(updated_by 已变)一律不动。 let mut admin_row = build_row(300); admin_row.updated_by = "admin-1".to_string(); - assert_eq!(resolve_default_point_product_migration(&admin_row), None); + assert!(resolve_default_point_product_migration(&admin_row).is_none()); + + // 中文注释:已经迁移过的行不重复迁移。 + let mut migrated_row = build_row(680); + migrated_row.bonus_points = 0; + migrated_row.badge_label = String::new(); + migrated_row.description = "680泥点".to_string(); + assert!(resolve_default_point_product_migration(&migrated_row).is_none()); } #[test] @@ -3934,173 +3992,64 @@ mod tests { } #[test] - fn membership_upgrade_amount_only_charges_price_difference() { - assert_eq!(membership_upgrade_amount_cents(6_990, 1_990), 5_000); - assert_eq!(membership_upgrade_amount_cents(19_990, 6_990), 13_000); - } - - #[test] - fn legacy_membership_tiers_share_new_pricing_ranks() { + fn membership_plan_window_recomputes_each_period_from_the_original_anchor() { + // 中文注释:1/31 开通 → 第 1 期到 2/28,第 2 期到 3/31;每期都从原始锚点重算。 + let anchor = beijing_local_micros(2026, 1, 31, 0); assert_eq!( - canonical_membership_pricing_tier(RuntimeProfileMembershipTier::Month), - RuntimeProfileMembershipTier::Starter, - ); - assert_eq!( - canonical_membership_pricing_tier(RuntimeProfileMembershipTier::Season), - RuntimeProfileMembershipTier::Basic, - ); - assert_eq!( - canonical_membership_pricing_tier(RuntimeProfileMembershipTier::Year), - RuntimeProfileMembershipTier::Pro, - ); - } - - #[test] - fn legacy_membership_tiers_can_migrate_to_new_equivalent_tiers_as_renewals() { - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Month, - RuntimeProfileMembershipTier::Starter, - ), - Ok(ActiveMembershipPurchaseMode::Renew), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Season, - RuntimeProfileMembershipTier::Basic, - ), - Ok(ActiveMembershipPurchaseMode::Renew), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Year, - RuntimeProfileMembershipTier::Pro, - ), - Ok(ActiveMembershipPurchaseMode::Renew), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Starter, - RuntimeProfileMembershipTier::Starter, - ), - Ok(ActiveMembershipPurchaseMode::Renew), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Month, - RuntimeProfileMembershipTier::Basic, - ), - Ok(ActiveMembershipPurchaseMode::Upgrade), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Season, - RuntimeProfileMembershipTier::Pro, - ), - Ok(ActiveMembershipPurchaseMode::Upgrade), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Year, - RuntimeProfileMembershipTier::Ultimate, - ), - Ok(ActiveMembershipPurchaseMode::Upgrade), - ); - assert!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Basic, - RuntimeProfileMembershipTier::Starter, + membership_cycle_window(anchor, 1), + ( + beijing_local_micros(2026, 1, 31, 0), + beijing_local_micros(2026, 2, 28, 0) ) - .is_err() ); - assert!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Year, - RuntimeProfileMembershipTier::Basic, - ) - .is_err() - ); - assert!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Basic, - RuntimeProfileMembershipTier::Season, - ) - .is_err() - ); - } - - #[test] - fn legacy_membership_migration_renews_and_preserves_cycle_timing() { - let started_at = Timestamp::from_micros_since_unix_epoch(PROFILE_RUNTIME_DAY_MICROS); - let expires_at = Timestamp::from_micros_since_unix_epoch(10 * PROFILE_RUNTIME_DAY_MICROS); - let current_reset_at = - Timestamp::from_micros_since_unix_epoch(5 * PROFILE_RUNTIME_DAY_MICROS); - let purchased_at = Timestamp::from_micros_since_unix_epoch(2 * PROFILE_RUNTIME_DAY_MICROS); - let row = apply_active_membership_renew_row( - ProfileMembership { - user_id: "user-1".to_string(), - status: RuntimeProfileMembershipStatus::Active, - tier: RuntimeProfileMembershipTier::Month, - started_at, - expires_at, - updated_at: started_at, - cycle_started_at: Some(started_at), - cycle_resets_at: Some(current_reset_at), - cycle_granted_points: 0, - cycle_remaining_points: 0, - cycle_period_days: 90, - }, - RuntimeProfileMembershipTier::Starter, - 30, - purchased_at, - ); - - assert_eq!(row.tier, RuntimeProfileMembershipTier::Starter); assert_eq!( - row.expires_at, - Timestamp::from_micros_since_unix_epoch(40 * PROFILE_RUNTIME_DAY_MICROS), + membership_cycle_window(anchor, 2), + ( + beijing_local_micros(2026, 2, 28, 0), + beijing_local_micros(2026, 3, 31, 0) + ) ); - assert_eq!(row.cycle_resets_at, Some(current_reset_at)); - assert_eq!(row.cycle_period_days, 90); - assert_eq!(row.cycle_granted_points, 0); - assert_eq!(row.cycle_remaining_points, 0); - assert_eq!(row.updated_at, purchased_at); } #[test] - fn active_membership_upgrade_switches_next_cycle_period_without_moving_reset() { - let started_at = Timestamp::from_micros_since_unix_epoch(1_000); - let expires_at = Timestamp::from_micros_since_unix_epoch(90_000); - let current_reset_at = Timestamp::from_micros_since_unix_epoch(30_000); - let updated_at = Timestamp::from_micros_since_unix_epoch(2_000); - let (row, period_points_delta) = apply_active_membership_upgrade_row( - ProfileMembership { - user_id: "user-1".to_string(), - status: RuntimeProfileMembershipStatus::Active, - tier: RuntimeProfileMembershipTier::Basic, - started_at, - expires_at, - updated_at: started_at, - cycle_started_at: Some(started_at), - cycle_resets_at: Some(current_reset_at), - cycle_granted_points: 800, - cycle_remaining_points: 120, - cycle_period_days: 90, - }, - RuntimeProfileMembershipTier::Pro, - 7, - 2_500, - updated_at, - ); + fn membership_cycle_advance_only_writes_when_a_new_period_starts() { + let anchor = beijing_local_micros(2026, 1, 31, 0); + let day = PROFILE_RUNTIME_DAY_MICROS; + let row = || ProfileMembership { + user_id: "user-1".to_string(), + status: RuntimeProfileMembershipStatus::Active, + started_at: Timestamp::from_micros_since_unix_epoch(anchor), + expires_at: Timestamp::from_micros_since_unix_epoch(anchor + 400 * day), + updated_at: Timestamp::from_micros_since_unix_epoch(anchor), + cycle_started_at: Some(Timestamp::from_micros_since_unix_epoch(anchor)), + cycle_resets_at: Some(Timestamp::from_micros_since_unix_epoch(anchor + 28 * day)), + cycle_granted_points: 400, + cycle_remaining_points: 120, + plan: RuntimeProfileMembershipPlan::Starter, + cycle_index: 1, + cycle_count: 12, + cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, + }; - assert_eq!(row.tier, RuntimeProfileMembershipTier::Pro); - assert_eq!(row.expires_at, expires_at); - assert_eq!(row.cycle_resets_at, Some(current_reset_at)); - assert_eq!(row.cycle_period_days, 7); - assert_eq!(row.cycle_granted_points, 2_500); - assert_eq!(row.cycle_remaining_points, 1_820); - assert_eq!(row.updated_at, updated_at); - assert_eq!(period_points_delta, 1_700); + let same_period = advance_profile_membership_cycle_to( + row(), + Timestamp::from_micros_since_unix_epoch(anchor + 10 * day), + 400, + ); + assert!(!same_period.advanced, "仍在本期时不应写账本"); + assert_eq!(same_period.row.cycle_index, 1); + assert_eq!(same_period.row.cycle_remaining_points, 120); + + let next_period = advance_profile_membership_cycle_to( + row(), + Timestamp::from_micros_since_unix_epoch(anchor + 28 * day), + 400, + ); + assert!(next_period.advanced); + assert_eq!(next_period.previous_remaining_points, 120); + assert_eq!(next_period.row.cycle_index, 2); + assert_eq!(next_period.row.cycle_granted_points, 400); + assert_eq!(next_period.row.cycle_remaining_points, 400); } #[test] @@ -4983,9 +4932,49 @@ fn build_profile_recharge_order_admin_entry_snapshot( active_hold: active_profile_recharge_refund_hold_for_order(ctx, &row.order_id) .map(|value| build_profile_recharge_refund_hold_snapshot_from_row(&value)), wallet: build_profile_admin_wallet_snapshot(ctx, &row.user_id), + membership_change: build_profile_membership_change_snapshot_from_order_row(row), } } +/// 订单行的会员变更快照 → 只读投影;非会员订单(`membership_change_kind` 为空)返回 `None`。 +fn build_profile_membership_change_snapshot_from_order_row( + row: &ProfileRechargeOrder, +) -> Option { + let change_kind = row.membership_change_kind?; + Some(RuntimeProfileMembershipOrderChangeSnapshot { + change_kind: change_kind.as_str().to_string(), + plan_before: row + .membership_plan_before + .map(|plan| plan.as_str().to_string()), + plan_after: row + .membership_plan_after + .map(|plan| plan.as_str().to_string()), + cycle_kind_before: row + .membership_cycle_kind_before + .map(|kind| kind.as_str().to_string()), + cycle_kind_after: row + .membership_cycle_kind_after + .map(|kind| kind.as_str().to_string()), + period_points_before: row.membership_period_points_before, + period_points_after: row.membership_period_points_after, + granted_points_delta: row.membership_granted_points_delta, + cycle_index: row.membership_cycle_index, + expires_at_before_micros: row + .membership_expires_at_before + .map(|value| value.to_micros_since_unix_epoch()), + expires_at_after_micros: row + .membership_expires_at_after + .map(|value| value.to_micros_since_unix_epoch()), + cycle_resets_at_before_micros: row + .membership_cycle_resets_at_before + .map(|value| value.to_micros_since_unix_epoch()), + cycle_resets_at_after_micros: row + .membership_cycle_resets_at_after + .map(|value| value.to_micros_since_unix_epoch()), + price_breakdown_json: row.membership_price_breakdown_json.clone(), + }) +} + fn preview_profile_recharge_refund_hold( ctx: &ReducerContext, input: RuntimeProfileRechargeRefundHoldPreviewInput, @@ -7995,11 +7984,6 @@ fn upsert_profile_recharge_product_config_record( input.duration_days, input.badge_label, input.description, - input.tier, - input.membership_period_points, - input.membership_period_days, - input.membership_queue_limit, - input.membership_discount_bps, input.enabled, input.sort_order, input.updated_at_micros, @@ -8033,7 +8017,6 @@ fn upsert_profile_recharge_product_config_record( duration_days: validated_input.duration_days, badge_label: validated_input.badge_label, description: validated_input.description, - tier: validated_input.tier, enabled: validated_input.enabled, sort_order: validated_input.sort_order, created_by: existing @@ -8046,16 +8029,82 @@ fn upsert_profile_recharge_product_config_record( .unwrap_or(updated_at), updated_by: validated_input.admin_user_id, updated_at, - membership_period_points: validated_input.membership_period_points, - membership_period_days: validated_input.membership_period_days, - membership_queue_limit: validated_input.membership_queue_limit, - membership_discount_bps: validated_input.membership_discount_bps, }); Ok(build_profile_recharge_product_config_snapshot_from_row( &inserted, )) } +fn list_profile_membership_plan_records( + ctx: &ReducerContext, + input: RuntimeProfileMembershipPlanAdminListInput, +) -> Result, String> { + build_runtime_profile_membership_plan_admin_list_input(input.admin_user_id) + .map_err(|error| error.to_string())?; + Ok(membership_plan_records(ctx)) +} + +/// 后台改价 / 改权益。`rank` 与 `plan` 不可改:`plan` 是主键(即身份),`rank` 是升级比较基准。 +fn upsert_profile_membership_plan_record( + ctx: &ReducerContext, + input: RuntimeProfileMembershipPlanAdminUpsertInput, +) -> Result { + let validated = build_runtime_profile_membership_plan_admin_upsert_input( + input.admin_user_id, + input.plan, + input.title, + input.month_price_cents, + input.year_price_cents, + input.period_points, + input.model_access, + input.concurrent_job_limit, + input.enabled, + input.sort_order, + input.updated_at_micros, + ) + .map_err(|error| error.to_string())?; + ensure_default_profile_membership_plan(ctx); + + let updated_at = Timestamp::from_micros_since_unix_epoch(validated.updated_at_micros); + let existing = ctx + .db + .profile_membership_plan() + .plan() + .find(&validated.plan); + // 代码内目录是 `rank` 的唯一来源:后台改价、改权益、改上下架都不会动升级比较基准。 + let rank = resolve_runtime_profile_membership_plan_row(validated.plan) + .map(|row| row.rank) + .unwrap_or(0); + + let inserted = ProfileMembershipPlan { + plan: validated.plan, + title: validated.title, + rank, + month_price_cents: validated.month_price_cents, + year_price_cents: validated.year_price_cents, + period_points: validated.period_points, + model_access: validated.model_access, + concurrent_job_limit: validated.concurrent_job_limit, + sort_order: validated.sort_order, + enabled: validated.enabled, + created_by: existing + .as_ref() + .map(|row| row.created_by.clone()) + .unwrap_or_else(|| validated.admin_user_id.clone()), + created_at: existing + .as_ref() + .map(|row| row.created_at) + .unwrap_or(updated_at), + updated_by: validated.admin_user_id, + updated_at, + }; + if let Some(row) = existing { + ctx.db.profile_membership_plan().plan().delete(&row.plan); + } + let inserted = ctx.db.profile_membership_plan().insert(inserted); + Ok(membership_plan_record_from_row(&inserted)) +} + fn upsert_profile_task_config_record( ctx: &ReducerContext, input: RuntimeProfileTaskConfigAdminUpsertInput, @@ -8816,7 +8865,6 @@ struct MembershipCycleAdvance { advanced: bool, previous_remaining_points: u64, cycle_started_at_micros: i64, - cycle_resets_at_micros: i64, } fn active_membership_row_at(row: &ProfileMembership, at: Timestamp) -> bool { @@ -8839,13 +8887,35 @@ fn membership_plan_period_points(ctx: &ReducerContext, plan: RuntimeProfileMembe .unwrap_or(0) } +/// 目录表行 → 领域目录记录(字段一一对应,仅做类型搬运)。 +fn membership_plan_record_from_row( + row: &ProfileMembershipPlan, +) -> RuntimeProfileMembershipPlanRecord { + RuntimeProfileMembershipPlanRecord { + plan: row.plan, + title: row.title.clone(), + rank: row.rank, + month_price_cents: row.month_price_cents, + year_price_cents: row.year_price_cents, + period_points: row.period_points, + model_access: row.model_access, + concurrent_job_limit: row.concurrent_job_limit, + sort_order: row.sort_order, + enabled: row.enabled, + } +} + fn membership_plan_snapshot( ctx: &ReducerContext, plan: RuntimeProfileMembershipPlan, cycle_kind: RuntimeProfileMembershipCycleKind, ) -> Option { - membership_plan_row(ctx, plan) - .map(|row| build_runtime_profile_membership_plan_snapshot(&row, cycle_kind)) + membership_plan_row(ctx, plan).map(|row| { + build_runtime_profile_membership_plan_snapshot( + &membership_plan_record_from_row(&row), + cycle_kind, + ) + }) } /// 充值中心与后台共用的目录行读取,按 `sort_order` 升序。 @@ -8908,7 +8978,6 @@ fn advance_profile_membership_cycle_to( advanced, previous_remaining_points, cycle_started_at_micros: window.0, - cycle_resets_at_micros: window.1, } } @@ -8950,7 +9019,10 @@ fn build_membership_order_product( plan: RuntimeProfileMembershipPlan, cycle_kind: RuntimeProfileMembershipCycleKind, ) -> RuntimeProfileRechargeProductSnapshot { - let snapshot = build_runtime_profile_membership_plan_snapshot(row, cycle_kind); + let snapshot = build_runtime_profile_membership_plan_snapshot( + &membership_plan_record_from_row(row), + cycle_kind, + ); let cycle_label = match cycle_kind { RuntimeProfileMembershipCycleKind::Monthly => "月付", RuntimeProfileMembershipCycleKind::Yearly => "年付", From 703a24b682148c3e258446ed5da9dd5e8d272cd6 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 16:59:49 +0800 Subject: [PATCH 017/232] =?UTF-8?q?=E9=87=8D=E6=9E=84(=E8=AE=A1=E8=B4=B9):?= =?UTF-8?q?=20=E4=BD=99=E9=A2=9D=E6=8E=A5=E5=8F=A3=E5=B9=B6=E5=88=97?= =?UTF-8?q?=E6=9A=B4=E9=9C=B2=E4=B8=89=E6=B1=A0=E5=B9=B6=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E4=BC=9A=E5=91=98=20tier=20=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 余额响应改为每日免费 / 月度 / 永久三池并列,不再暴露总额与会员限时泥点 - 会员响应与充值中心以 plan 取代 tier,充值中心改为输出会员目录 - 微信虚拟支付按目录档位校验会员商品,不再依赖会员商品配置行 - 同步 shared-contracts 测试夹具到新的三池与 plan 口径 Co-authored-by: Junie --- .../crates/api-server/src/runtime_profile.rs | 309 +++++++----------- .../crates/shared-contracts/src/runtime.rs | 157 +++++---- 2 files changed, 200 insertions(+), 266 deletions(-) diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index d92ae2454..c656ec873 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -16,16 +16,15 @@ use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileCodeOperationRecord, RuntimeProfileDailyFreePointsRecord, RuntimeProfileFeedbackEvidenceRecord, RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord, - RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord, - RuntimeProfileMembershipTier, RuntimeProfileRechargeCenterRecord, - RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, - RuntimeProfileRechargeProductConfigRecord, RuntimeProfileRechargeProductKind, - RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode, - RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, - RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, - RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, - RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, - RuntimeTrackingScopeKind, + RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipPlanRecord, + RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, + RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductConfigRecord, + RuntimeProfileRechargeProductKind, RuntimeProfileRechargeProductRecord, + RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, + RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord, + RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle, + RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType, + RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind, }; use platform_wechat::pay::{WechatPayError, WechatPayNotifyOrder}; use serde::{Deserialize, Serialize}; @@ -40,9 +39,6 @@ use shared_contracts::runtime::{ AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse, ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, PROFILE_FEEDBACK_STATUS_OPEN, - PROFILE_MEMBERSHIP_TIER_BASIC, PROFILE_MEMBERSHIP_TIER_MONTH, PROFILE_MEMBERSHIP_TIER_NORMAL, - PROFILE_MEMBERSHIP_TIER_PRO, PROFILE_MEMBERSHIP_TIER_SEASON, PROFILE_MEMBERSHIP_TIER_STARTER, - PROFILE_MEMBERSHIP_TIER_ULTIMATE, PROFILE_MEMBERSHIP_TIER_YEAR, PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP, PROFILE_RECHARGE_PRODUCT_KIND_POINTS, PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE, PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE, @@ -65,7 +61,7 @@ use shared_contracts::runtime::{ ProfileDailyFreePointsResponse, ProfileDashboardSummaryResponse, ProfileFeedbackEvidenceItemResponse, ProfileFeedbackSubmissionResponse, ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse, - ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfileMudPointBalanceResponse, + ProfileMembershipPlanResponse, ProfileMembershipResponse, ProfileMudPointBalanceResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, ProfileRechargeProductConfigAdminListResponse, ProfileRechargeProductConfigAdminResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminListResponse, @@ -1089,12 +1085,6 @@ pub async fn admin_upsert_profile_recharge_product( AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), ) })?; - let tier = parse_profile_membership_tier(&payload.tier).map_err(|error| { - runtime_profile_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), - ) - })?; let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; let record = state .spacetime_client() @@ -1109,11 +1099,6 @@ pub async fn admin_upsert_profile_recharge_product( payload.duration_days, payload.badge_label.unwrap_or_default(), payload.description.unwrap_or_default(), - tier, - payload.membership_period_points, - payload.membership_period_days, - payload.membership_queue_limit, - payload.membership_discount_bps, payload.enabled, payload.sort_order.unwrap_or(10), updated_at_micros as i64, @@ -1685,23 +1670,30 @@ fn build_wechat_virtual_pay_params( order: &RuntimeProfileRechargeOrderRecord, openid: &str, ) -> Result { - let product = match order.kind { + let buy_quantity = match order.kind { RuntimeProfileRechargeProductKind::Points => center .point_products .iter() .find(|item| item.product_id == order.product_id) + .map(|item| item.points_amount) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST) .with_message("当前充值商品不存在,请刷新后再试") })?, - RuntimeProfileRechargeProductKind::Membership => center - .membership_products - .iter() - .find(|item| item.product_id == order.product_id) - .ok_or_else(|| { - AppError::from_status(StatusCode::BAD_REQUEST) - .with_message("当前充值商品不存在,请刷新后再试") - })?, + RuntimeProfileRechargeProductKind::Membership => { + // 会员商品不在商品配置表里:确认档位仍在目录中即可,购买数量固定为 1。 + let (plan, _) = + module_runtime::parse_runtime_profile_membership_product_id(&order.product_id) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("当前会员套餐不存在,请刷新后再试") + })?; + if !center.membership_plans.iter().any(|item| item.plan == plan) { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("当前会员套餐不存在,请刷新后再试")); + } + 1 + } }; let identity = state .wechat_auth_service() @@ -1729,10 +1721,6 @@ fn build_wechat_virtual_pay_params( RuntimeProfileRechargeProductKind::Points => "short_series_coin", RuntimeProfileRechargeProductKind::Membership => "short_series_goods", }; - let buy_quantity = match product.kind { - RuntimeProfileRechargeProductKind::Points => product.points_amount, - RuntimeProfileRechargeProductKind::Membership => 1, - }; let mut sign_data = serde_json::json!({ "offerId": offer_id, "buyQuantity": buy_quantity, @@ -1846,11 +1834,10 @@ fn build_profile_recharge_center_response( ) -> ProfileRechargeCenterResponse { let mud_point_balance = build_profile_mud_point_balance_response(&record); ProfileRechargeCenterResponse { - wallet_balance: record.wallet_balance, mud_point_balance, membership: ProfileMembershipResponse { status: record.membership.status.as_str().to_string(), - tier: record.membership.tier.as_str().to_string(), + plan: record.membership.plan.as_str().to_string(), started_at: record.membership.started_at, expires_at: record.membership.expires_at, updated_at: record.membership.updated_at, @@ -1858,22 +1845,19 @@ fn build_profile_recharge_center_response( cycle_resets_at: record.membership.cycle_resets_at, cycle_granted_points: record.membership.cycle_granted_points, cycle_remaining_points: record.membership.cycle_remaining_points, - cycle_period_days: record.membership.cycle_period_days, + cycle_index: record.membership.cycle_index, + cycle_count: record.membership.cycle_count, + cycle_kind: record.membership.cycle_kind.as_str().to_string(), }, point_products: record .point_products .into_iter() .map(build_profile_recharge_product_response) .collect(), - membership_products: record - .membership_products + membership_plans: record + .membership_plans .into_iter() - .map(build_profile_recharge_product_response) - .collect(), - benefits: record - .benefits - .into_iter() - .map(build_profile_membership_benefit_response) + .map(build_profile_membership_plan_response) .collect(), latest_order: record .latest_order @@ -1887,12 +1871,13 @@ fn build_profile_mud_point_balance_response( record: &RuntimeProfileRechargeCenterRecord, ) -> ProfileMudPointBalanceResponse { let daily_free_points = record.daily_free_points.remaining_points; - let limited_points = record.membership.cycle_remaining_points; + let monthly_points = record.membership.cycle_remaining_points; + // 永久泥点 = 单一总额 − 每日免费 − 月度;存储层仍是 `wallet_balance` 单一权威。 let permanent_points = record .wallet_balance .saturating_sub(daily_free_points) - .saturating_sub(limited_points); - let limited_expires_at = (limited_points > 0) + .saturating_sub(monthly_points); + let monthly_resets_at = (monthly_points > 0) .then(|| { record .membership @@ -1903,13 +1888,12 @@ fn build_profile_mud_point_balance_response( .flatten(); ProfileMudPointBalanceResponse { - total_points: record.wallet_balance, - permanent_points, - limited_points, - limited_expires_at, daily_free_points, daily_free_reset_points: record.daily_free_points.reset_points, daily_free_resets_at: record.daily_free_points.resets_at.clone(), + monthly_points, + monthly_resets_at, + permanent_points, } } @@ -1938,27 +1922,24 @@ fn build_profile_recharge_product_response( duration_days: record.duration_days, badge_label: record.badge_label, description: record.description, - tier: record.tier.as_str().to_string(), - membership_period_points: record.membership_period_points, - membership_period_days: record.membership_period_days, - membership_queue_limit: record.membership_queue_limit, - membership_discount_bps: record.membership_discount_bps, } } -fn build_profile_membership_benefit_response( - record: RuntimeProfileMembershipBenefitRecord, -) -> ProfileMembershipBenefitResponse { - ProfileMembershipBenefitResponse { - benefit_name: record.benefit_name, - normal_value: record.normal_value, - month_value: record.month_value, - season_value: record.season_value, - year_value: record.year_value, - starter_value: record.starter_value, - basic_value: record.basic_value, - pro_value: record.pro_value, - ultimate_value: record.ultimate_value, +/// 会员档位目录 → 对外响应;`concurrent_job_limit` 的 `128` 哨兵直接透出,由前端展示为“不设上限”。 +fn build_profile_membership_plan_response( + record: RuntimeProfileMembershipPlanRecord, +) -> ProfileMembershipPlanResponse { + ProfileMembershipPlanResponse { + plan: record.plan.as_str().to_string(), + title: record.title, + rank: record.rank, + month_price_cents: record.month_price_cents, + year_price_cents: record.year_price_cents, + period_points: record.period_points, + model_access: record.model_access.as_str().to_string(), + concurrent_job_limit: record.concurrent_job_limit, + sort_order: record.sort_order, + enabled: record.enabled, } } @@ -2211,11 +2192,6 @@ fn build_profile_recharge_product_config_admin_response( duration_days: record.duration_days, badge_label: record.badge_label, description: record.description, - tier: format_profile_membership_tier(record.tier).to_string(), - membership_period_points: record.membership_period_points, - membership_period_days: record.membership_period_days, - membership_queue_limit: record.membership_queue_limit, - membership_discount_bps: record.membership_discount_bps, enabled: record.enabled, sort_order: record.sort_order, created_by: record.created_by, @@ -2318,20 +2294,6 @@ fn parse_profile_recharge_product_kind( } } -fn parse_profile_membership_tier(raw: &str) -> Result { - match raw.trim().to_ascii_lowercase().as_str() { - PROFILE_MEMBERSHIP_TIER_NORMAL => Ok(RuntimeProfileMembershipTier::Normal), - PROFILE_MEMBERSHIP_TIER_MONTH => Ok(RuntimeProfileMembershipTier::Month), - PROFILE_MEMBERSHIP_TIER_SEASON => Ok(RuntimeProfileMembershipTier::Season), - PROFILE_MEMBERSHIP_TIER_YEAR => Ok(RuntimeProfileMembershipTier::Year), - PROFILE_MEMBERSHIP_TIER_STARTER => Ok(RuntimeProfileMembershipTier::Starter), - PROFILE_MEMBERSHIP_TIER_BASIC => Ok(RuntimeProfileMembershipTier::Basic), - PROFILE_MEMBERSHIP_TIER_PRO => Ok(RuntimeProfileMembershipTier::Pro), - PROFILE_MEMBERSHIP_TIER_ULTIMATE => Ok(RuntimeProfileMembershipTier::Ultimate), - _ => Err("会员档位无效".to_string()), - } -} - fn parse_tracking_scope_kind(raw: &str) -> Result { match raw.trim().to_ascii_lowercase().as_str() { TRACKING_SCOPE_KIND_SITE => Ok(RuntimeTrackingScopeKind::Site), @@ -2375,19 +2337,6 @@ fn format_profile_recharge_product_kind(kind: RuntimeProfileRechargeProductKind) } } -fn format_profile_membership_tier(tier: RuntimeProfileMembershipTier) -> &'static str { - match tier { - RuntimeProfileMembershipTier::Normal => PROFILE_MEMBERSHIP_TIER_NORMAL, - RuntimeProfileMembershipTier::Month => PROFILE_MEMBERSHIP_TIER_MONTH, - RuntimeProfileMembershipTier::Season => PROFILE_MEMBERSHIP_TIER_SEASON, - RuntimeProfileMembershipTier::Year => PROFILE_MEMBERSHIP_TIER_YEAR, - RuntimeProfileMembershipTier::Starter => PROFILE_MEMBERSHIP_TIER_STARTER, - RuntimeProfileMembershipTier::Basic => PROFILE_MEMBERSHIP_TIER_BASIC, - RuntimeProfileMembershipTier::Pro => PROFILE_MEMBERSHIP_TIER_PRO, - RuntimeProfileMembershipTier::Ultimate => PROFILE_MEMBERSHIP_TIER_ULTIMATE, - } -} - fn format_tracking_scope_kind(scope_kind: RuntimeTrackingScopeKind) -> &'static str { match scope_kind { RuntimeTrackingScopeKind::Site => TRACKING_SCOPE_KIND_SITE, @@ -2454,11 +2403,13 @@ mod tests { use module_auth::{ResolveWechatLoginInput, WechatIdentityProfile}; use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL, - RuntimeProfileDailyFreePointsRecord, RuntimeProfileMembershipRecord, - RuntimeProfileMembershipStatus, RuntimeProfileMembershipTier, - RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, - RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductKind, - RuntimeProfileRechargeProductRecord, RuntimeProfileWalletLedgerSourceType, + RuntimeProfileDailyFreePointsRecord, RuntimeProfileMembershipCycleKind, + RuntimeProfileMembershipModelAccess, RuntimeProfileMembershipPlan, + RuntimeProfileMembershipPlanRecord, RuntimeProfileMembershipRecord, + RuntimeProfileMembershipStatus, RuntimeProfileRechargeCenterRecord, + RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, + RuntimeProfileRechargeProductKind, RuntimeProfileRechargeProductRecord, + RuntimeProfileWalletLedgerSourceType, }; use super::{ @@ -2566,7 +2517,7 @@ mod tests { membership: RuntimeProfileMembershipRecord { user_id: "user-1".to_string(), status: RuntimeProfileMembershipStatus::Active, - tier: RuntimeProfileMembershipTier::Starter, + plan: RuntimeProfileMembershipPlan::Starter, started_at: Some("2026-07-01T00:00:00Z".to_string()), started_at_micros: Some(1_783_036_800_000_000), expires_at: Some("2026-08-01T00:00:00Z".to_string()), @@ -2579,11 +2530,12 @@ mod tests { cycle_resets_at_micros: Some(1_784_073_600_000_000), cycle_granted_points: 80, cycle_remaining_points: 80, - cycle_period_days: 7, + cycle_index: 1, + cycle_count: 1, + cycle_kind: RuntimeProfileMembershipCycleKind::Monthly, }, point_products: vec![], - membership_products: vec![], - benefits: vec![], + membership_plans: vec![], latest_order: None, has_points_recharged: false, daily_free_points: daily_free_points_record(), @@ -2591,13 +2543,12 @@ mod tests { let balance = build_profile_mud_point_balance_response(&record); - assert_eq!(balance.total_points, 200); assert_eq!(balance.permanent_points, 100); - assert_eq!(balance.limited_points, 80); + assert_eq!(balance.monthly_points, 80); assert_eq!(balance.daily_free_points, 20); assert_eq!(balance.daily_free_reset_points, 35); assert_eq!( - balance.limited_expires_at.as_deref(), + balance.monthly_resets_at.as_deref(), Some("2026-07-15T00:00:00Z"), ); assert_eq!(balance.daily_free_resets_at, "2026-07-12T16:00:00Z"); @@ -3418,10 +3369,10 @@ mod tests { let order = RuntimeProfileRechargeOrderRecord { order_id: "memberorder01".to_string(), user_id: user_id.clone(), - product_id: "member_month".to_string(), - product_title: "月卡".to_string(), + product_id: "membership-starter-yearly".to_string(), + product_title: "Starter·年付".to_string(), kind: RuntimeProfileRechargeProductKind::Membership, - amount_cents: 2800, + amount_cents: 39000, status: RuntimeProfileRechargeOrderStatus::Pending, payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL .to_string(), @@ -3447,7 +3398,7 @@ mod tests { membership: RuntimeProfileMembershipRecord { user_id: user_id.clone(), status: RuntimeProfileMembershipStatus::Normal, - tier: RuntimeProfileMembershipTier::Normal, + plan: RuntimeProfileMembershipPlan::Normal, started_at: None, started_at_micros: None, expires_at: None, @@ -3460,26 +3411,24 @@ mod tests { cycle_resets_at_micros: None, cycle_granted_points: 0, cycle_remaining_points: 0, - cycle_period_days: 30, + cycle_index: 1, + cycle_count: 12, + cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, }, point_products: vec![], - membership_products: vec![RuntimeProfileRechargeProductRecord { - product_id: "member_month".to_string(), - title: "月卡".to_string(), - price_cents: 2800, - kind: RuntimeProfileRechargeProductKind::Membership, - points_amount: 0, - bonus_points: 0, - duration_days: 30, - badge_label: String::new(), - description: "30天会员".to_string(), - tier: RuntimeProfileMembershipTier::Month, - membership_period_points: 0, - membership_period_days: 30, - membership_queue_limit: 0, - membership_discount_bps: 0, + membership_plans: vec![RuntimeProfileMembershipPlanRecord { + plan: RuntimeProfileMembershipPlan::Starter, + title: "Starter".to_string(), + rank: 1, + month_price_cents: 3900, + year_price_cents: 39000, + period_points: 400, + model_access: RuntimeProfileMembershipModelAccess::Basic, + concurrent_job_limit: 1, + sort_order: 1, + enabled: true, }], - benefits: vec![], + latest_order: None, has_points_recharged: false, daily_free_points: daily_free_points_record(), @@ -3500,8 +3449,8 @@ mod tests { assert_eq!(params.mode, "short_series_goods"); assert_eq!(sign_data["buyQuantity"], 1); assert_eq!(sign_data["offerId"], "offer-1"); - assert_eq!(sign_data["productId"], "member_month"); - assert_eq!(sign_data["goodsPrice"], 2800); + assert_eq!(sign_data["productId"], "membership-starter-yearly"); + assert_eq!(sign_data["goodsPrice"], 39000); assert_eq!(sign_data["outTradeNo"], "memberorder01"); assert_eq!(attach["paymentChannel"], "wechat_mp_virtual"); assert!(!params.pay_sig.is_empty()); @@ -3563,7 +3512,7 @@ mod tests { membership: RuntimeProfileMembershipRecord { user_id: user_id.clone(), status: RuntimeProfileMembershipStatus::Normal, - tier: RuntimeProfileMembershipTier::Normal, + plan: RuntimeProfileMembershipPlan::Normal, started_at: None, started_at_micros: None, expires_at: None, @@ -3576,7 +3525,9 @@ mod tests { cycle_resets_at_micros: None, cycle_granted_points: 0, cycle_remaining_points: 0, - cycle_period_days: 30, + cycle_index: 1, + cycle_count: 12, + cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, }, point_products: vec![RuntimeProfileRechargeProductRecord { product_id: "points_60".to_string(), @@ -3588,14 +3539,8 @@ mod tests { duration_days: 0, badge_label: "首充双倍".to_string(), description: "60+60泥点".to_string(), - tier: RuntimeProfileMembershipTier::Normal, - membership_period_points: 0, - membership_period_days: 0, - membership_queue_limit: 0, - membership_discount_bps: 0, }], - membership_products: vec![], - benefits: vec![], + membership_plans: vec![], latest_order: None, has_points_recharged: true, daily_free_points: daily_free_points_record(), @@ -3623,7 +3568,7 @@ mod tests { } #[tokio::test] - async fn wechat_virtual_pay_params_accept_admin_membership_product_ids() { + async fn wechat_virtual_pay_params_accept_catalog_membership_product_ids() { let state = seed_authenticated_state_with_config(AppConfig { wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()), wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()), @@ -3646,12 +3591,12 @@ mod tests { .expect("wechat identity should seed"); let user_id = wechat_login.user.id.clone(); let order = RuntimeProfileRechargeOrderRecord { - order_id: "item01order01".to_string(), + order_id: "starterorder01".to_string(), user_id: user_id.clone(), - product_id: "item01".to_string(), - product_title: "测试道具".to_string(), + product_id: "membership-starter-monthly".to_string(), + product_title: "Starter·月付".to_string(), kind: RuntimeProfileRechargeProductKind::Membership, - amount_cents: 100, + amount_cents: 3900, status: RuntimeProfileRechargeOrderStatus::Pending, payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL .to_string(), @@ -3677,7 +3622,7 @@ mod tests { membership: RuntimeProfileMembershipRecord { user_id: user_id.clone(), status: RuntimeProfileMembershipStatus::Normal, - tier: RuntimeProfileMembershipTier::Normal, + plan: RuntimeProfileMembershipPlan::Normal, started_at: None, started_at_micros: None, expires_at: None, @@ -3690,26 +3635,24 @@ mod tests { cycle_resets_at_micros: None, cycle_granted_points: 0, cycle_remaining_points: 0, - cycle_period_days: 30, + cycle_index: 1, + cycle_count: 12, + cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, }, point_products: vec![], - membership_products: vec![RuntimeProfileRechargeProductRecord { - product_id: "item01".to_string(), - title: "测试道具".to_string(), - price_cents: 100, - kind: RuntimeProfileRechargeProductKind::Membership, - points_amount: 0, - bonus_points: 0, - duration_days: 30, - badge_label: String::new(), - description: "30天会员".to_string(), - tier: RuntimeProfileMembershipTier::Month, - membership_period_points: 0, - membership_period_days: 30, - membership_queue_limit: 0, - membership_discount_bps: 0, + membership_plans: vec![RuntimeProfileMembershipPlanRecord { + plan: RuntimeProfileMembershipPlan::Starter, + title: "Starter".to_string(), + rank: 1, + month_price_cents: 3900, + year_price_cents: 39000, + period_points: 400, + model_access: RuntimeProfileMembershipModelAccess::Basic, + concurrent_job_limit: 1, + sort_order: 1, + enabled: true, }], - benefits: vec![], + latest_order: None, has_points_recharged: false, daily_free_points: daily_free_points_record(), @@ -3721,9 +3664,9 @@ mod tests { serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json"); assert_eq!(params.mode, "short_series_goods"); - assert_eq!(sign_data["productId"], "item01"); - assert_eq!(sign_data["goodsPrice"], 100); - assert_eq!(sign_data["outTradeNo"], "item01order01"); + assert_eq!(sign_data["productId"], "membership-starter-monthly"); + assert_eq!(sign_data["goodsPrice"], 3900); + assert_eq!(sign_data["outTradeNo"], "starterorder01"); } #[test] @@ -3872,7 +3815,7 @@ mod tests { membership: RuntimeProfileMembershipRecord { user_id: user_id.clone(), status: RuntimeProfileMembershipStatus::Normal, - tier: RuntimeProfileMembershipTier::Normal, + plan: RuntimeProfileMembershipPlan::Normal, started_at: None, started_at_micros: None, expires_at: None, @@ -3885,7 +3828,9 @@ mod tests { cycle_resets_at_micros: None, cycle_granted_points: 0, cycle_remaining_points: 0, - cycle_period_days: 30, + cycle_index: 1, + cycle_count: 12, + cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, }, point_products: vec![RuntimeProfileRechargeProductRecord { product_id: "points_60".to_string(), @@ -3897,14 +3842,8 @@ mod tests { duration_days: 0, badge_label: "首充双倍".to_string(), description: "60+60泥点".to_string(), - tier: RuntimeProfileMembershipTier::Normal, - membership_period_points: 0, - membership_period_days: 0, - membership_queue_limit: 0, - membership_discount_bps: 0, }], - membership_products: vec![], - benefits: vec![], + membership_plans: vec![], latest_order: None, has_points_recharged: false, daily_free_points: daily_free_points_record(), diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index ac339f8ac..e00836bdb 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -35,14 +35,6 @@ pub const PROFILE_TASK_STATUS_CLAIMED: &str = "claimed"; pub const PROFILE_TASK_STATUS_DISABLED: &str = "disabled"; pub const PROFILE_RECHARGE_PRODUCT_KIND_POINTS: &str = "points"; pub const PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP: &str = "membership"; -pub const PROFILE_MEMBERSHIP_TIER_NORMAL: &str = "normal"; -pub const PROFILE_MEMBERSHIP_TIER_MONTH: &str = "month"; -pub const PROFILE_MEMBERSHIP_TIER_SEASON: &str = "season"; -pub const PROFILE_MEMBERSHIP_TIER_YEAR: &str = "year"; -pub const PROFILE_MEMBERSHIP_TIER_STARTER: &str = "starter"; -pub const PROFILE_MEMBERSHIP_TIER_BASIC: &str = "basic"; -pub const PROFILE_MEMBERSHIP_TIER_PRO: &str = "pro"; -pub const PROFILE_MEMBERSHIP_TIER_ULTIMATE: &str = "ultimate"; pub const PROFILE_FEEDBACK_STATUS_OPEN: &str = "open"; pub const TRACKING_SCOPE_KIND_SITE: &str = "site"; pub const TRACKING_SCOPE_KIND_WORK: &str = "work"; @@ -210,13 +202,15 @@ pub struct ProfileDailyFreePointsResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileMudPointBalanceResponse { - pub total_points: u64, - pub permanent_points: u64, - pub limited_points: u64, - pub limited_expires_at: Option, + /// 每日免费泥点:北京业务日有效,跨日清零并重置。 pub daily_free_points: u64, pub daily_free_reset_points: u64, pub daily_free_resets_at: String, + /// 月度泥点:会员每期额度,到刷新时刻清零再发新额度。 + pub monthly_points: u64, + pub monthly_resets_at: Option, + /// 永久泥点:不过期,创作照常扣点。 + pub permanent_points: u64, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -249,32 +243,32 @@ pub struct ProfileRechargeProductResponse { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: String, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct ProfileMembershipBenefitResponse { - pub benefit_name: String, - pub normal_value: String, - pub month_value: String, - pub season_value: String, - pub year_value: String, - pub starter_value: String, - pub basic_value: String, - pub pro_value: String, - pub ultimate_value: String, +pub struct ProfileMembershipPlanResponse { + /// 档位 token:`normal` / `starter` / `plus` / `pro` / `max`。 + pub plan: String, + pub title: String, + pub rank: u8, + pub month_price_cents: u64, + pub year_price_cents: u64, + pub period_points: u64, + /// 模型权限 token:`basic` / `full`。 + pub model_access: String, + /// 并发上限;`128` 表示不设上限。 + pub concurrent_job_limit: u32, + pub sort_order: i32, + pub enabled: bool, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileMembershipResponse { pub status: String, - pub tier: String, + /// 当前档位 token;非会员与已到期都是 `normal`。 + pub plan: String, pub started_at: Option, pub expires_at: Option, pub updated_at: Option, @@ -282,7 +276,12 @@ pub struct ProfileMembershipResponse { pub cycle_resets_at: Option, pub cycle_granted_points: u64, pub cycle_remaining_points: u64, - pub cycle_period_days: u32, + /// 当前期序号(1-based)。 + pub cycle_index: u32, + /// 本次购买总期数(月付 1 / 年付 12)。 + pub cycle_count: u32, + /// 周期 token:`monthly` / `yearly`。 + pub cycle_kind: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -350,12 +349,11 @@ pub struct WechatNativePaymentResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileRechargeCenterResponse { - pub wallet_balance: u64, pub mud_point_balance: ProfileMudPointBalanceResponse, pub membership: ProfileMembershipResponse, pub point_products: Vec, - pub membership_products: Vec, - pub benefits: Vec, + /// 会员档位目录(后台可改价),会员购买 / 升级的价格与权益都以它为准。 + pub membership_plans: Vec, pub latest_order: Option, pub has_points_recharged: bool, pub daily_free_points: ProfileDailyFreePointsResponse, @@ -577,11 +575,6 @@ pub struct ProfileRechargeProductConfigAdminResponse { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: String, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, pub enabled: bool, pub sort_order: i32, pub created_by: String, @@ -666,15 +659,6 @@ pub struct AdminUpsertProfileRechargeProductRequest { pub badge_label: Option, #[serde(default)] pub description: Option, - pub tier: String, - #[serde(default)] - pub membership_period_points: u64, - #[serde(default)] - pub membership_period_days: u32, - #[serde(default)] - pub membership_queue_limit: u32, - #[serde(default)] - pub membership_discount_bps: u32, #[serde(default = "default_true")] pub enabled: bool, #[serde(default)] @@ -1325,13 +1309,12 @@ mod tests { fn empty_mud_point_balance() -> ProfileMudPointBalanceResponse { ProfileMudPointBalanceResponse { - total_points: 0, - permanent_points: 0, - limited_points: 0, - limited_expires_at: None, daily_free_points: 0, daily_free_reset_points: 20, daily_free_resets_at: "2026-07-12T16:00:00Z".to_string(), + monthly_points: 0, + monthly_resets_at: None, + permanent_points: 0, } } @@ -1608,27 +1591,27 @@ mod tests { #[test] fn profile_recharge_center_response_uses_camel_case_fields() { let payload = serde_json::to_value(ProfileRechargeCenterResponse { - wallet_balance: 29, mud_point_balance: ProfileMudPointBalanceResponse { - total_points: 29, - permanent_points: 4, - limited_points: 5, - limited_expires_at: Some("2026-05-25T10:00:00Z".to_string()), daily_free_points: 20, daily_free_reset_points: 20, daily_free_resets_at: "2026-07-12T16:00:00Z".to_string(), + monthly_points: 5, + monthly_resets_at: Some("2026-05-25T10:00:00Z".to_string()), + permanent_points: 4, }, membership: ProfileMembershipResponse { status: "active".to_string(), - tier: "month".to_string(), + plan: "plus".to_string(), started_at: Some("2026-04-25T10:00:00Z".to_string()), expires_at: Some("2026-05-25T10:00:00Z".to_string()), updated_at: Some("2026-04-25T10:00:00Z".to_string()), cycle_started_at: Some("2026-04-25T10:00:00Z".to_string()), cycle_resets_at: Some("2026-05-25T10:00:00Z".to_string()), - cycle_granted_points: 800, + cycle_granted_points: 1_150, cycle_remaining_points: 320, - cycle_period_days: 30, + cycle_index: 1, + cycle_count: 1, + cycle_kind: "monthly".to_string(), }, point_products: vec![ProfileRechargeProductResponse { product_id: "points_60".to_string(), @@ -1638,16 +1621,21 @@ mod tests { points_amount: 60, bonus_points: 60, duration_days: 0, - badge_label: "首充双倍".to_string(), - description: "首充送60泥点".to_string(), - tier: "normal".to_string(), - membership_period_points: 0, - membership_period_days: 0, - membership_queue_limit: 0, - membership_discount_bps: 0, + badge_label: String::new(), + description: "60泥点".to_string(), + }], + membership_plans: vec![ProfileMembershipPlanResponse { + plan: "plus".to_string(), + title: "Plus".to_string(), + rank: 2, + month_price_cents: 9_900, + year_price_cents: 99_000, + period_points: 1_150, + model_access: "full".to_string(), + concurrent_job_limit: 3, + sort_order: 2, + enabled: true, }], - membership_products: vec![], - benefits: vec![], latest_order: None, has_points_recharged: false, daily_free_points: ProfileDailyFreePointsResponse { @@ -1660,21 +1648,28 @@ mod tests { }) .expect("payload should serialize"); - assert_eq!(payload["walletBalance"], json!(29)); assert_eq!(payload["mudPointBalance"]["permanentPoints"], json!(4)); - assert_eq!(payload["mudPointBalance"]["limitedPoints"], json!(5)); + assert_eq!(payload["mudPointBalance"]["monthlyPoints"], json!(5)); assert_eq!(payload["mudPointBalance"]["dailyFreePoints"], json!(20)); + assert_eq!( + payload["mudPointBalance"]["monthlyResetsAt"], + json!("2026-05-25T10:00:00Z") + ); + assert_eq!(payload["membership"]["plan"], json!("plus")); assert_eq!( payload["membership"]["expiresAt"], json!("2026-05-25T10:00:00Z") ); assert_eq!(payload["membership"]["cycleRemainingPoints"], json!(320)); + assert_eq!(payload["membership"]["cycleKind"], json!("monthly")); assert_eq!(payload["pointProducts"][0]["productId"], json!("points_60")); assert_eq!(payload["pointProducts"][0]["title"], json!("60泥点")); assert_eq!(payload["pointProducts"][0]["priceCents"], json!(600)); + assert_eq!(payload["pointProducts"][0]["description"], json!("60泥点")); + assert_eq!(payload["membershipPlans"][0]["plan"], json!("plus")); assert_eq!( - payload["pointProducts"][0]["description"], - json!("首充送60泥点") + payload["membershipPlans"][0]["concurrentJobLimit"], + json!(3) ); assert_eq!(payload["hasPointsRecharged"], json!(false)); assert_eq!(payload["dailyFreePoints"]["grantedPoints"], json!(20)); @@ -1712,11 +1707,10 @@ mod tests { expiration_last_error: None, }; let center = ProfileRechargeCenterResponse { - wallet_balance: 0, mud_point_balance: empty_mud_point_balance(), membership: ProfileMembershipResponse { status: "normal".to_string(), - tier: "normal".to_string(), + plan: "normal".to_string(), started_at: None, expires_at: None, updated_at: None, @@ -1724,11 +1718,12 @@ mod tests { cycle_resets_at: None, cycle_granted_points: 0, cycle_remaining_points: 0, - cycle_period_days: 30, + cycle_index: 1, + cycle_count: 1, + cycle_kind: "monthly".to_string(), }, point_products: vec![], - membership_products: vec![], - benefits: vec![], + membership_plans: vec![], latest_order: None, has_points_recharged: false, daily_free_points: ProfileDailyFreePointsResponse { @@ -1788,11 +1783,10 @@ mod tests { expiration_last_error: None, }; let center = ProfileRechargeCenterResponse { - wallet_balance: 0, mud_point_balance: empty_mud_point_balance(), membership: ProfileMembershipResponse { status: "normal".to_string(), - tier: "normal".to_string(), + plan: "normal".to_string(), started_at: None, expires_at: None, updated_at: None, @@ -1800,11 +1794,12 @@ mod tests { cycle_resets_at: None, cycle_granted_points: 0, cycle_remaining_points: 0, - cycle_period_days: 30, + cycle_index: 1, + cycle_count: 1, + cycle_kind: "monthly".to_string(), }, point_products: vec![], - membership_products: vec![], - benefits: vec![], + membership_plans: vec![], latest_order: None, has_points_recharged: false, daily_free_points: ProfileDailyFreePointsResponse { From c772600d594066e6c2f3fef83657d1fc1189920d 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 17:36:26 +0800 Subject: [PATCH 018/232] =?UTF-8?q?feat(=E4=BC=9A=E5=91=98):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E8=A1=A5=E5=B7=AE=E5=8D=87=E7=BA=A7=E6=8A=A5=E4=BB=B7?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E3=80=81=E5=90=8E=E5=8F=B0=E6=A1=A3=E4=BD=8D?= =?UTF-8?q?=E8=AF=BB=E5=86=99=E4=B8=8E=E8=AE=A2=E5=8D=95=E6=9D=83=E7=9B=8A?= =?UTF-8?q?=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 POST /api/profile/membership/upgrade-quote:只读报价,下单时后端用同一纯函数重算 新增后台 /admin/api/profile/membership-plans 的读取与按档位改价改权益接口 订单与管理端契约补上会员权益变更快照(变更前后档位、周期、补点、有效期与价格拆分) 报价结果统一走「模块侧快照 + application 记录侧投影」两段式,与既有 DTO 约定一致 Co-authored-by: Junie --- server-rs/crates/api-server/src/admin.rs | 6 + .../crates/api-server/src/modules/admin.rs | 10 +- .../crates/api-server/src/modules/profile.rs | 12 +- .../crates/api-server/src/runtime_profile.rs | 148 +++++++++++++++++- .../crates/module-runtime/src/application.rs | 20 +++ .../crates/module-runtime/src/commands.rs | 11 ++ server-rs/crates/module-runtime/src/domain.rs | 50 ++++++ .../crates/shared-contracts/src/runtime.rs | 54 +++++++ .../crates/spacetime-client/src/active.rs | 2 + .../src/active/mapper/runtime_profile.rs | 39 +++++ .../spacetime-client/src/active/runtime.rs | 33 ++++ .../spacetime-client/src/module_bindings.rs | 8 + ...file_membership_upgrade_quote_procedure.rs | 61 ++++++++ ...membership_upgrade_quote_get_input_type.rs | 18 +++ ...hip_upgrade_quote_procedure_result_type.rs | 19 +++ ...le_membership_upgrade_quote_record_type.rs | 25 +++ .../src/runtime/active/profile.rs | 75 +++++++++ 17 files changed, 583 insertions(+), 8 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_profile_membership_upgrade_quote_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_record_type.rs diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 5b3e3afc5..3ca5513e3 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -2238,6 +2238,7 @@ fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermission "/admin/api/profile/tasks" | "/admin/api/profile/tasks/disable" => AnyTab(&["tasks"]), "/admin/api/profile/wallet-config" => AnyTab(&["profile-wallet"]), "/admin/api/profile/recharge-products" => AnyTab(&["recharge-products"]), + "/admin/api/profile/membership-plans" => AnyTab(&["membership-plans"]), path if path.starts_with("/admin/api/profile/recharge-") => AnyTab(&["recharge-orders"]), "/admin/api/profile/wallet-restriction" => AnyTab(&["recharge-orders"]), "/admin/api/profile/users/reconcile-consumption" => { @@ -7444,6 +7445,11 @@ mod tests { Method::GET, "/admin/api/profile/recharge-products", ), + ( + "membership-plans", + Method::GET, + "/admin/api/profile/membership-plans", + ), ( "recharge-orders", Method::GET, diff --git a/server-rs/crates/api-server/src/modules/admin.rs b/server-rs/crates/api-server/src/modules/admin.rs index e16b4a82b..e8eefc21c 100644 --- a/server-rs/crates/api-server/src/modules/admin.rs +++ b/server-rs/crates/api-server/src/modules/admin.rs @@ -28,8 +28,9 @@ use crate::{ runtime_profile::{ admin_disable_profile_redeem_code, admin_disable_profile_task_config, admin_get_profile_wallet_config, admin_list_profile_invite_codes, - admin_list_profile_recharge_products, admin_list_profile_redeem_codes, - admin_list_profile_task_configs, admin_upsert_profile_invite_code, + admin_list_profile_membership_plans, admin_list_profile_recharge_products, + admin_list_profile_redeem_codes, admin_list_profile_task_configs, + admin_upsert_profile_invite_code, admin_upsert_profile_membership_plan, admin_upsert_profile_recharge_product, admin_upsert_profile_redeem_code, admin_upsert_profile_task_config, admin_upsert_profile_wallet_config, }, @@ -170,6 +171,10 @@ pub fn router(state: AppState) -> Router { "/admin/api/profile/recharge-products", get(admin_list_profile_recharge_products).post(admin_upsert_profile_recharge_product), ), + ( + "/admin/api/profile/membership-plans", + get(admin_list_profile_membership_plans).post(admin_upsert_profile_membership_plan), + ), ( "/admin/api/profile/recharge-orders", get(admin_list_recharge_orders), @@ -277,6 +282,7 @@ mod route_contract_tests { ("/admin/api/profile/tasks/disable", &["POST"]), ("/admin/api/profile/wallet-config", &["GET", "POST"]), ("/admin/api/profile/recharge-products", &["GET", "POST"]), + ("/admin/api/profile/membership-plans", &["GET", "POST"]), ("/admin/api/profile/recharge-orders", &["GET"]), ("/admin/api/profile/recharge-refunds/preview", &["POST"]), ("/admin/api/profile/recharge-refunds/execute", &["POST"]), diff --git a/server-rs/crates/api-server/src/modules/profile.rs b/server-rs/crates/api-server/src/modules/profile.rs index de6768102..633424889 100644 --- a/server-rs/crates/api-server/src/modules/profile.rs +++ b/server-rs/crates/api-server/src/modules/profile.rs @@ -13,8 +13,9 @@ use crate::{ runtime_profile::{ claim_profile_task_reward, confirm_wechat_profile_recharge_order, create_profile_recharge_order, get_profile_analytics_metric, get_profile_dashboard, - get_profile_recharge_center, get_profile_referral_invite_center, get_profile_task_center, - get_profile_wallet_ledger, redeem_profile_referral_invite_code, redeem_profile_reward_code, + get_profile_membership_upgrade_quote, get_profile_recharge_center, + get_profile_referral_invite_center, get_profile_task_center, get_profile_wallet_ledger, + redeem_profile_referral_invite_code, redeem_profile_reward_code, stream_wechat_profile_recharge_order_events, submit_profile_feedback, }, state::AppState, @@ -73,6 +74,13 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/profile/membership/upgrade-quote", + post(get_profile_membership_upgrade_quote).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/profile/recharge/orders", post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index c656ec873..8083f5065 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -34,9 +34,10 @@ use shared_contracts::runtime::{ ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER, ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, AdminDisableProfileRedeemCodeRequest, AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest, - AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest, - AdminUpsertProfileTaskConfigRequest, AdminUpsertProfileWalletConfigRequest, - AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse, + AdminUpsertProfileMembershipPlanRequest, AdminUpsertProfileRechargeProductRequest, + AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, + AdminUpsertProfileWalletConfigRequest, AnalyticsBucketMetricResponse, + AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse, ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, PROFILE_FEEDBACK_STATUS_OPEN, PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP, PROFILE_RECHARGE_PRODUCT_KIND_POINTS, @@ -61,7 +62,9 @@ use shared_contracts::runtime::{ ProfileDailyFreePointsResponse, ProfileDashboardSummaryResponse, ProfileFeedbackEvidenceItemResponse, ProfileFeedbackSubmissionResponse, ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse, - ProfileMembershipPlanResponse, ProfileMembershipResponse, ProfileMudPointBalanceResponse, + ProfileMembershipPlanAdminListResponse, ProfileMembershipPlanResponse, + ProfileMembershipResponse, ProfileMembershipUpgradeQuoteRequest, + ProfileMembershipUpgradeQuoteResponse, ProfileMudPointBalanceResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, ProfileRechargeProductConfigAdminListResponse, ProfileRechargeProductConfigAdminResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminListResponse, @@ -240,6 +243,50 @@ pub async fn get_profile_recharge_center( )) } +/// 补差升级报价:只读。入参是目标档位,周期沿用当前会员周期(有效期内不支持月年互换)。 +pub async fn get_profile_membership_upgrade_quote( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let user_id = authenticated.claims().user_id().to_string(); + let target_plan = + parse_runtime_profile_membership_plan(&payload.target_plan).map_err(|error| { + runtime_profile_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), + ) + })?; + let quote = state + .spacetime_client() + .get_profile_membership_upgrade_quote(user_id, target_plan) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + ProfileMembershipUpgradeQuoteResponse { + current_plan: quote.current_plan, + target_plan: quote.target_plan, + cycle_kind: quote.cycle_kind, + cycle_index: quote.cycle_index, + cycle_count: quote.cycle_count, + amount_cents: quote.amount_cents, + granted_points_delta: quote.granted_points_delta, + monthly_balance_after: quote.monthly_balance_after, + expires_at: Some(quote.expires_at), + cycle_resets_at: Some(quote.cycle_resets_at), + remaining_full_months: quote.remaining_full_months, + }, + )) +} + pub async fn create_profile_recharge_order( State(state): State, Extension(request_context): Extension, @@ -1117,6 +1164,83 @@ pub async fn admin_upsert_profile_recharge_product( )) } +pub async fn admin_list_profile_membership_plans( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, +) -> Result, Response> { + let entries = state + .spacetime_client() + .admin_list_profile_membership_plans(admin.session().subject.clone()) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + ProfileMembershipPlanAdminListResponse { + entries: entries + .into_iter() + .map(build_profile_membership_plan_response) + .collect(), + }, + )) +} + +/// 后台按档位改会员价格与权益。`rank` 不在请求里:升级比较基准由代码内目录决定。 +pub async fn admin_upsert_profile_membership_plan( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, + Json(payload): Json, +) -> Result, Response> { + let plan = parse_runtime_profile_membership_plan(&payload.plan).map_err(|error| { + runtime_profile_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), + ) + })?; + let model_access = parse_runtime_profile_membership_model_access(&payload.model_access) + .map_err(|error| { + runtime_profile_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), + ) + })?; + let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + let record = state + .spacetime_client() + .admin_upsert_profile_membership_plan( + admin.session().subject.clone(), + plan, + payload.title, + payload.month_price_cents, + payload.year_price_cents, + payload.period_points, + model_access, + payload.concurrent_job_limit, + payload.enabled, + payload.sort_order.unwrap_or(10), + updated_at_micros as i64, + ) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + build_profile_membership_plan_response(record), + )) +} + pub async fn admin_list_profile_redeem_codes( State(state): State, Extension(request_context): Extension, @@ -2294,6 +2418,22 @@ fn parse_profile_recharge_product_kind( } } +/// 会员档位 token → 枚举。JSON 没有枚举,但解析必须严格:未知 token 一律 400。 +fn parse_runtime_profile_membership_plan( + raw: &str, +) -> Result { + module_runtime::RuntimeProfileMembershipPlan::parse(raw) + .ok_or_else(|| "会员档位无效".to_string()) +} + +/// 模型权限 token → 枚举;`basic` = 仅基础模型,`full` = 基础及高性能模型。 +fn parse_runtime_profile_membership_model_access( + raw: &str, +) -> Result { + module_runtime::RuntimeProfileMembershipModelAccess::parse(raw) + .ok_or_else(|| "模型权限无效".to_string()) +} + fn parse_tracking_scope_kind(raw: &str) -> Result { match raw.trim().to_ascii_lowercase().as_str() { TRACKING_SCOPE_KIND_SITE => Ok(RuntimeTrackingScopeKind::Site), diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 4779ec1fa..38454c755 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -1128,6 +1128,26 @@ pub fn build_runtime_profile_membership_record( } } +pub fn build_runtime_profile_membership_upgrade_quote_record( + snapshot: RuntimeProfileMembershipUpgradeQuoteSnapshot, +) -> RuntimeProfileMembershipUpgradeQuoteRecord { + RuntimeProfileMembershipUpgradeQuoteRecord { + current_plan: snapshot.current_plan, + target_plan: snapshot.target_plan, + cycle_kind: snapshot.cycle_kind, + cycle_index: snapshot.cycle_index, + cycle_count: snapshot.cycle_count, + amount_cents: snapshot.amount_cents, + granted_points_delta: snapshot.granted_points_delta, + monthly_balance_after: snapshot.monthly_balance_after, + expires_at: format_utc_micros(snapshot.expires_at_micros), + expires_at_micros: snapshot.expires_at_micros, + cycle_resets_at: format_utc_micros(snapshot.cycle_resets_at_micros), + cycle_resets_at_micros: snapshot.cycle_resets_at_micros, + remaining_full_months: snapshot.remaining_full_months, + } +} + pub fn build_runtime_profile_recharge_order_record( snapshot: RuntimeProfileRechargeOrderSnapshot, ) -> RuntimeProfileRechargeOrderRecord { diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index ce8f521f3..02d11db91 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -499,6 +499,17 @@ pub fn build_runtime_profile_membership_plan_admin_upsert_input( }) } +pub fn build_runtime_profile_membership_upgrade_quote_input( + user_id: String, + target_plan: RuntimeProfileMembershipPlan, +) -> Result { + let user_id = normalize_runtime_profile_user_id(user_id)?; + Ok(RuntimeProfileMembershipUpgradeQuoteGetInput { + user_id, + target_plan, + }) +} + pub fn build_runtime_profile_recharge_order_paid_input( order_id: String, paid_at_micros: i64, diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 6f48a750d..dd5f8d519 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1846,6 +1846,56 @@ pub struct RuntimeProfileMembershipPlanAdminProcedureResult { pub error_message: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipUpgradeQuoteGetInput { + pub user_id: String, + pub target_plan: RuntimeProfileMembershipPlan, +} + +/// 补差升级报价快照(模块侧,时间戳为 micros)。报价与下单重算共用同一套规则。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipUpgradeQuoteSnapshot { + pub current_plan: String, + pub target_plan: String, + pub cycle_kind: String, + pub cycle_index: u32, + pub cycle_count: u32, + 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 remaining_full_months: u32, +} + +/// 补差升级报价的只读投影(记录侧,附 RFC3339 时间)。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipUpgradeQuoteRecord { + pub current_plan: String, + pub target_plan: String, + pub cycle_kind: String, + pub cycle_index: u32, + pub cycle_count: u32, + pub amount_cents: u64, + pub granted_points_delta: u64, + pub monthly_balance_after: u64, + pub expires_at: String, + pub expires_at_micros: i64, + pub cycle_resets_at: String, + pub cycle_resets_at_micros: i64, + pub remaining_full_months: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipUpgradeQuoteProcedureResult { + pub ok: bool, + pub quote: Option, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileRechargeCenterGetInput { diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index e00836bdb..6e21e9015 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -665,6 +665,60 @@ pub struct AdminUpsertProfileRechargeProductRequest { pub sort_order: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileMembershipUpgradeQuoteRequest { + /// 目标档位 token:`starter` / `plus` / `pro` / `max`。 + pub target_plan: String, +} + +/// 补差升级报价:付款前展示应付金额、补点与变更后的月度余额;下单时后端会用同一函数重算。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileMembershipUpgradeQuoteResponse { + pub current_plan: String, + pub target_plan: String, + /// 周期 token:`monthly` / `yearly`;有效期内不支持月年互换,所以变更前后一致。 + pub cycle_kind: String, + pub cycle_index: u32, + pub cycle_count: u32, + /// 应付金额(分);月付 = 月价差,年付 = 年价差 ÷ 12 × (后续完整月数 + 本期剩余比例),向上取整到分。 + pub amount_cents: u64, + /// 本期补点;年付按本期剩余比例向下取整。 + pub granted_points_delta: u64, + /// 变更后月度余额 = 原剩余月度余额 + 补点(已用点不返还,永久泥点不动)。 + pub monthly_balance_after: u64, + pub expires_at: Option, + pub cycle_resets_at: Option, + pub remaining_full_months: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileMembershipPlanAdminListResponse { + pub entries: Vec, +} + +/// 后台按档位改会员价格与权益;`rank` 不在请求里,升级比较基准由代码内目录决定。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AdminUpsertProfileMembershipPlanRequest { + /// 档位 token,作为主键即档位身份。 + pub plan: String, + pub title: String, + pub month_price_cents: u64, + pub year_price_cents: u64, + pub period_points: u64, + /// 模型权限 token:`basic` / `full`;本期只落字段、不做服务端拦截。 + pub model_access: String, + /// 并发上限;`128` 表示不设上限。 + pub concurrent_job_limit: u32, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub sort_order: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct AdminUpsertProfileWalletConfigRequest { diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index 86921c9ca..61f50ea68 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -89,6 +89,8 @@ use module_runtime::{ build_runtime_profile_invite_code_admin_upsert_input, build_runtime_profile_invite_code_record, build_runtime_profile_membership_plan_admin_list_input, build_runtime_profile_membership_plan_admin_upsert_input, + build_runtime_profile_membership_upgrade_quote_input, + build_runtime_profile_membership_upgrade_quote_record, build_runtime_profile_recharge_center_get_input, build_runtime_profile_recharge_center_record, build_runtime_profile_recharge_order_close_input, build_runtime_profile_recharge_order_create_input, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs index b61af2978..bc7d939be 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs @@ -2046,6 +2046,17 @@ pub(crate) fn map_runtime_profile_membership_order_change_snapshot( } } +impl From + for RuntimeProfileMembershipUpgradeQuoteGetInput +{ + fn from(input: module_runtime::RuntimeProfileMembershipUpgradeQuoteGetInput) -> Self { + Self { + user_id: input.user_id, + target_plan: map_runtime_profile_membership_plan(input.target_plan), + } + } +} + impl From for RuntimeProfileMembershipPlanAdminListInput { @@ -2076,6 +2087,34 @@ impl From } } +pub(crate) fn map_runtime_profile_membership_upgrade_quote_procedure_result( + result: RuntimeProfileMembershipUpgradeQuoteProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let quote = result + .quote + .ok_or_else(|| SpacetimeClientError::missing_snapshot("会员升级报价"))?; + + Ok(build_runtime_profile_membership_upgrade_quote_record( + module_runtime::RuntimeProfileMembershipUpgradeQuoteSnapshot { + current_plan: quote.current_plan, + target_plan: quote.target_plan, + cycle_kind: quote.cycle_kind, + cycle_index: quote.cycle_index, + cycle_count: quote.cycle_count, + amount_cents: quote.amount_cents, + granted_points_delta: quote.granted_points_delta, + monthly_balance_after: quote.monthly_balance_after, + expires_at_micros: quote.expires_at_micros, + cycle_resets_at_micros: quote.cycle_resets_at_micros, + remaining_full_months: quote.remaining_full_months, + }, + )) +} + pub(crate) fn map_runtime_profile_membership_plan_admin_list_procedure_result( result: RuntimeProfileMembershipPlanAdminListProcedureResult, ) -> Result, SpacetimeClientError> { diff --git a/server-rs/crates/spacetime-client/src/active/runtime.rs b/server-rs/crates/spacetime-client/src/active/runtime.rs index 866d78b5c..83895efd5 100644 --- a/server-rs/crates/spacetime-client/src/active/runtime.rs +++ b/server-rs/crates/spacetime-client/src/active/runtime.rs @@ -1695,6 +1695,39 @@ impl SpacetimeClient { .await } + /// 补差升级报价:只读,用来在付款前展示应付金额与补点;下单时后端会用同一函数重算。 + pub async fn get_profile_membership_upgrade_quote( + &self, + user_id: String, + target_plan: module_runtime::RuntimeProfileMembershipPlan, + ) -> Result + { + let procedure_input = + build_runtime_profile_membership_upgrade_quote_input(user_id, target_plan) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect( + "get_profile_membership_upgrade_quote", + move |connection, sender| { + connection + .procedures() + .get_profile_membership_upgrade_quote_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_membership_upgrade_quote_procedure_result, + ); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + /// 后台读取会员档位目录(价格与权益的唯一真相源)。 pub async fn admin_list_profile_membership_plans( &self, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 3615b2453..637214065 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -478,6 +478,7 @@ pub mod get_game_distribution_version_and_return_procedure; pub mod get_llm_router_account_and_return_procedure; pub mod get_owner_game_distribution_version_and_return_procedure; pub mod get_profile_dashboard_procedure; +pub mod get_profile_membership_upgrade_quote_procedure; pub mod get_profile_recharge_center_procedure; pub mod get_profile_recharge_order_and_return_procedure; pub mod get_profile_recharge_refund_and_return_procedure; @@ -662,6 +663,9 @@ pub mod runtime_profile_membership_plan_record_type; pub mod runtime_profile_membership_plan_type; pub mod runtime_profile_membership_snapshot_type; pub mod runtime_profile_membership_status_type; +pub mod runtime_profile_membership_upgrade_quote_get_input_type; +pub mod runtime_profile_membership_upgrade_quote_procedure_result_type; +pub mod runtime_profile_membership_upgrade_quote_record_type; pub mod runtime_profile_recharge_center_get_input_type; pub mod runtime_profile_recharge_center_procedure_result_type; pub mod runtime_profile_recharge_center_snapshot_type; @@ -1291,6 +1295,7 @@ pub use get_game_distribution_version_and_return_procedure::get_game_distributio pub use get_llm_router_account_and_return_procedure::get_llm_router_account_and_return; pub use get_owner_game_distribution_version_and_return_procedure::get_owner_game_distribution_version_and_return; pub use get_profile_dashboard_procedure::get_profile_dashboard; +pub use get_profile_membership_upgrade_quote_procedure::get_profile_membership_upgrade_quote; pub use get_profile_recharge_center_procedure::get_profile_recharge_center; pub use get_profile_recharge_order_and_return_procedure::get_profile_recharge_order_and_return; pub use get_profile_recharge_refund_and_return_procedure::get_profile_recharge_refund_and_return; @@ -1475,6 +1480,9 @@ pub use runtime_profile_membership_plan_record_type::RuntimeProfileMembershipPla pub use runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; pub use runtime_profile_membership_snapshot_type::RuntimeProfileMembershipSnapshot; pub use runtime_profile_membership_status_type::RuntimeProfileMembershipStatus; +pub use runtime_profile_membership_upgrade_quote_get_input_type::RuntimeProfileMembershipUpgradeQuoteGetInput; +pub use runtime_profile_membership_upgrade_quote_procedure_result_type::RuntimeProfileMembershipUpgradeQuoteProcedureResult; +pub use runtime_profile_membership_upgrade_quote_record_type::RuntimeProfileMembershipUpgradeQuoteRecord; pub use runtime_profile_recharge_center_get_input_type::RuntimeProfileRechargeCenterGetInput; pub use runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult; pub use runtime_profile_recharge_center_snapshot_type::RuntimeProfileRechargeCenterSnapshot; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_membership_upgrade_quote_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_membership_upgrade_quote_procedure.rs new file mode 100644 index 000000000..dc37950df --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_membership_upgrade_quote_procedure.rs @@ -0,0 +1,61 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_membership_upgrade_quote_get_input_type::RuntimeProfileMembershipUpgradeQuoteGetInput; +use super::runtime_profile_membership_upgrade_quote_procedure_result_type::RuntimeProfileMembershipUpgradeQuoteProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetProfileMembershipUpgradeQuoteArgs { + pub input: RuntimeProfileMembershipUpgradeQuoteGetInput, +} + +impl __sdk::InModule for GetProfileMembershipUpgradeQuoteArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_profile_membership_upgrade_quote`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_profile_membership_upgrade_quote { + fn get_profile_membership_upgrade_quote( + &self, + input: RuntimeProfileMembershipUpgradeQuoteGetInput, + ) { + self.get_profile_membership_upgrade_quote_then(input, |_, _| {}); + } + + fn get_profile_membership_upgrade_quote_then( + &self, + input: RuntimeProfileMembershipUpgradeQuoteGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_profile_membership_upgrade_quote for super::RemoteProcedures { + fn get_profile_membership_upgrade_quote_then( + &self, + input: RuntimeProfileMembershipUpgradeQuoteGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, RuntimeProfileMembershipUpgradeQuoteProcedureResult>( + "get_profile_membership_upgrade_quote", + GetProfileMembershipUpgradeQuoteArgs { input, }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_get_input_type.rs new file mode 100644 index 000000000..2d554d508 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_get_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipUpgradeQuoteGetInput { + pub user_id: String, + pub target_plan: RuntimeProfileMembershipPlan, +} + +impl __sdk::InModule for RuntimeProfileMembershipUpgradeQuoteGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_procedure_result_type.rs new file mode 100644 index 000000000..92168b485 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_membership_upgrade_quote_record_type::RuntimeProfileMembershipUpgradeQuoteRecord; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipUpgradeQuoteProcedureResult { + pub ok: bool, + pub quote: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileMembershipUpgradeQuoteProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_record_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_record_type.rs new file mode 100644 index 000000000..504002690 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_record_type.rs @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipUpgradeQuoteRecord { + pub current_plan: String, + pub target_plan: String, + pub cycle_kind: String, + pub cycle_index: u32, + pub cycle_count: u32, + 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 remaining_full_months: u32, +} + +impl __sdk::InModule for RuntimeProfileMembershipUpgradeQuoteRecord { + type Module = super::RemoteModule; +} 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 e6da80f97..7a1867ce7 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -1192,6 +1192,26 @@ pub fn admin_upsert_profile_membership_plan( } } +// 补差升级报价:只读,下单时后端会用同一函数重算并落订单快照,前端展示的数字不是真相源。 +#[spacetimedb::procedure] +pub fn get_profile_membership_upgrade_quote( + ctx: &mut ProcedureContext, + input: RuntimeProfileMembershipUpgradeQuoteGetInput, +) -> RuntimeProfileMembershipUpgradeQuoteProcedureResult { + match ctx.try_with_tx(|tx| build_profile_membership_upgrade_quote_record(tx, input.clone())) { + Ok(quote) => RuntimeProfileMembershipUpgradeQuoteProcedureResult { + ok: true, + quote: Some(quote), + error_message: None, + }, + Err(message) => RuntimeProfileMembershipUpgradeQuoteProcedureResult { + ok: false, + quote: None, + error_message: Some(message), + }, + } +} + // 新用户注册赠送由后端注册链路调用;流水 ID 固定,保证重试不重复发放。 #[spacetimedb::procedure] pub fn grant_new_user_registration_wallet_reward( @@ -8044,6 +8064,61 @@ fn list_profile_membership_plan_records( Ok(membership_plan_records(ctx)) } +/// 补差升级报价。非会员、降档、月年互换、当前套餐重复购买都返回可读错误,不做静默兜底。 +fn build_profile_membership_upgrade_quote_record( + ctx: &ReducerContext, + input: RuntimeProfileMembershipUpgradeQuoteGetInput, +) -> Result { + let validated = + build_runtime_profile_membership_upgrade_quote_input(input.user_id, input.target_plan) + .map_err(|error| error.to_string())?; + refresh_profile_wallet_expiring_points(ctx, &validated.user_id, ctx.timestamp); + + let Some(row) = ctx + .db + .profile_membership() + .user_id() + .find(&validated.user_id.to_string()) + .filter(|row| active_membership_row_at(row, ctx.timestamp)) + else { + return Err("当前不是会员,请直接选择套餐购买".to_string()); + }; + if row.plan == validated.target_plan { + return Err("当前套餐已生效,无需重复购买".to_string()); + } + let target = membership_plan_snapshot(ctx, validated.target_plan, row.cycle_kind) + .ok_or_else(|| format!("会员目录缺少档位 {}", validated.target_plan.as_str()))?; + if !target.enabled { + return Err(format!( + "会员档位 {} 当前不可购买", + validated.target_plan.as_str() + )); + } + let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind) + .ok_or_else(|| format!("会员目录缺少档位 {}", row.plan.as_str()))?; + let quote = quote_runtime_profile_membership_upgrade(membership_upgrade_quote_input( + &row, + ¤t, + &target, + ctx.timestamp, + )) + .map_err(|rejection| format!("会员升级被拒绝: {}", rejection.as_str()))?; + + Ok(RuntimeProfileMembershipUpgradeQuoteSnapshot { + current_plan: current.plan.as_str().to_string(), + target_plan: target.plan.as_str().to_string(), + cycle_kind: target.cycle_kind.as_str().to_string(), + cycle_index: quote.cycle_index, + cycle_count: target.cycle_count, + amount_cents: quote.amount_cents, + granted_points_delta: quote.granted_points_delta, + monthly_balance_after: quote.monthly_balance_after, + expires_at_micros: quote.expires_at_micros, + cycle_resets_at_micros: quote.cycle_resets_at_micros, + remaining_full_months: quote.remaining_full_months, + }) +} + /// 后台改价 / 改权益。`rank` 与 `plan` 不可改:`plan` 是主键(即身份),`rank` 是升级比较基准。 fn upsert_profile_membership_plan_record( ctx: &ReducerContext, From abc92d230b37a86b5e0ca6fa7800774f4628cac1 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 19:00:08 +0800 Subject: [PATCH 019/232] =?UTF-8?q?chore(=E5=A5=91=E7=BA=A6):=20=E4=B8=89?= =?UTF-8?q?=E6=B1=A0=E4=BD=99=E9=A2=9D=20DTO=20=E4=BA=A4=E7=BB=99=20ts-rs?= =?UTF-8?q?=20=E7=94=9F=E6=88=90=EF=BC=8C=E5=90=8C=E6=AD=A5=20SpacetimeDB?= =?UTF-8?q?=20=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 给 ProfileMudPointBalanceResponse 加 ts-bindings 特性下的 ts_rs::TS 导出,u64 标 ts(type = "number") 避免前端分叉成 bigint - 新增 packages/shared/src/contracts/generated/ProfileMudPointBalanceResponse.ts,runtime.ts 改为 re-export 不再手写第二份字段 - 新增 packages/shared/src/utils/mudPoints.ts:派生展示用合计与北京时间月度刷新文案 - 同步 spacetime-client 生成绑定:升级报价改名为 snapshot,与 module 侧改动对齐 Co-authored-by: Junie --- .../ProfileMudPointBalanceResponse.ts | 19 +++++ packages/shared/src/contracts/runtime.ts | 83 +++++++++++-------- packages/shared/src/index.ts | 1 + packages/shared/src/utils/mudPoints.ts | 73 ++++++++++++++++ .../crates/shared-contracts/src/runtime.rs | 15 ++++ .../spacetime-client/src/module_bindings.rs | 4 +- ...hip_upgrade_quote_procedure_result_type.rs | 4 +- ...membership_upgrade_quote_snapshot_type.rs} | 4 +- 8 files changed, 163 insertions(+), 40 deletions(-) create mode 100644 packages/shared/src/contracts/generated/ProfileMudPointBalanceResponse.ts create mode 100644 packages/shared/src/utils/mudPoints.ts rename server-rs/crates/spacetime-client/src/module_bindings/{runtime_profile_membership_upgrade_quote_record_type.rs => runtime_profile_membership_upgrade_quote_snapshot_type.rs} (85%) diff --git a/packages/shared/src/contracts/generated/ProfileMudPointBalanceResponse.ts b/packages/shared/src/contracts/generated/ProfileMudPointBalanceResponse.ts new file mode 100644 index 000000000..65cd3039a --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMudPointBalanceResponse.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ProfileMudPointBalanceResponse = { + /** + * 每日免费泥点:北京业务日有效,跨日清零并重置。 + */ + dailyFreePoints: number; + dailyFreeResetPoints: number; + dailyFreeResetsAt: string; + /** + * 月度泥点:会员每期额度,到刷新时刻清零再发新额度。 + */ + monthlyPoints: number; + monthlyResetsAt: string | null; + /** + * 永久泥点:不过期,创作照常扣点。 + */ + permanentPoints: number; +}; diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 2d30cc81d..e8a184688 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -1,4 +1,7 @@ import type { JsonObject } from './common'; +import type { ProfileMudPointBalanceResponse as ProfileMudPointBalance } from './generated/ProfileMudPointBalanceResponse'; + +export type { ProfileMudPointBalance }; export const SAVE_SNAPSHOT_VERSION = 2; export const DEFAULT_MUSIC_VOLUME = 0.42; @@ -118,15 +121,17 @@ export type ExternalApiKeyMutationResponse = { export type ProfileRechargeProductKind = 'points' | 'membership'; export type ProfileMembershipStatus = 'normal' | 'active'; -export type ProfileMembershipTier = +/** 会员档位:`normal` 是非会员占位档位,不可购买。 */ +export type ProfileMembershipPlan = | 'normal' - | 'month' - | 'season' - | 'year' | 'starter' - | 'basic' + | 'plus' | 'pro' - | 'ultimate'; + | 'max'; +/** 会员周期:有效期内不支持月年互换,到期后重新选择。 */ +export type ProfileMembershipCycleKind = 'monthly' | 'yearly'; +/** 模型权限:`basic` 仅基础模型,`full` 基础及高性能模型。 */ +export type ProfileMembershipModelAccess = 'basic' | 'full'; export type ProfileRechargeOrderStatus = | 'pending' | 'paid' @@ -145,28 +150,27 @@ export type ProfileRechargeProduct = { durationDays: number; badgeLabel: string; description: string; - tier: ProfileMembershipTier; - membershipPeriodPoints: number; - membershipPeriodDays: number; - membershipQueueLimit: number; - membershipDiscountBps: number; }; -export type ProfileMembershipBenefit = { - benefitName: string; - normalValue: string; - monthValue: string; - seasonValue: string; - yearValue: string; - starterValue: string; - basicValue: string; - proValue: string; - ultimateValue: string; +/** 会员档位目录行;价格与权益的唯一真相源是后端 `profile_membership_plan` 表。 */ +export type ProfileMembershipPlanRecord = { + plan: ProfileMembershipPlan; + title: string; + rank: number; + monthPriceCents: number; + yearPriceCents: number; + periodPoints: number; + modelAccess: ProfileMembershipModelAccess; + /** 并发上限;`128` 表示不设上限。 */ + concurrentJobLimit: number; + sortOrder: number; + enabled: boolean; }; export type ProfileMembership = { status: ProfileMembershipStatus; - tier: ProfileMembershipTier; + /** 当前档位;非会员与已到期都是 `normal`。 */ + plan: ProfileMembershipPlan; startedAt: string | null; expiresAt: string | null; updatedAt: string | null; @@ -174,17 +178,29 @@ export type ProfileMembership = { cycleResetsAt: string | null; cycleGrantedPoints: number; cycleRemainingPoints: number; - cyclePeriodDays: number; + /** 当前期序号(1-based)。 */ + cycleIndex: number; + /** 本次购买总期数(月付 1 / 年付 12)。 */ + cycleCount: number; + cycleKind: ProfileMembershipCycleKind; }; -export type ProfileMudPointBalance = { - totalPoints: number; - permanentPoints: number; - limitedPoints: number; - limitedExpiresAt: string | null; - dailyFreePoints: number; - dailyFreeResetPoints: number; - dailyFreeResetsAt: string; +/** 补差升级报价;下单时后端会用同一套规则重算,前端展示的数字不是真相源。 */ +export type ProfileMembershipUpgradeQuote = { + currentPlan: ProfileMembershipPlan; + targetPlan: ProfileMembershipPlan; + cycleKind: ProfileMembershipCycleKind; + cycleIndex: number; + cycleCount: number; + /** 应付金额(分)。 */ + amountCents: number; + /** 本期补点。 */ + grantedPointsDelta: number; + /** 变更后月度余额 = 原剩余月度余额 + 补点。 */ + monthlyBalanceAfter: number; + expiresAt: string | null; + cycleResetsAt: string | null; + remainingFullMonths: number; }; export type ProfileRechargeOrder = { @@ -207,13 +223,12 @@ export type ProfileRechargeOrder = { }; export type ProfileRechargeCenterResponse = { - walletBalance: number; // TODO mirror backend: should not nullable mudPointBalance?: ProfileMudPointBalance; membership: ProfileMembership; pointProducts: ProfileRechargeProduct[]; - membershipProducts: ProfileRechargeProduct[]; - benefits: ProfileMembershipBenefit[]; + /** 会员档位目录(后台可改价);会员购买 / 升级的价格与权益以它为准。 */ + membershipPlans: ProfileMembershipPlanRecord[]; latestOrder: ProfileRechargeOrder | null; hasPointsRecharged: boolean; dailyFreePoints?: ProfileDailyFreePoints; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 87fb5dc00..9505d6b0f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -12,4 +12,5 @@ export * from './http'; export * from './llm/narrativeLanguage'; export * from './llm/parsers'; export * from './stores/createProfileWalletStore'; +export * from './utils/mudPoints'; export * from './utils/signedReadUrlCache'; diff --git a/packages/shared/src/utils/mudPoints.ts b/packages/shared/src/utils/mudPoints.ts new file mode 100644 index 000000000..d20282667 --- /dev/null +++ b/packages/shared/src/utils/mudPoints.ts @@ -0,0 +1,73 @@ +import type { ProfileMudPointBalance } from '../contracts/runtime'; + +/** + * 泥点余额的展示视图。 + * + * 后端只并列暴露三个池子(每日免费 / 月度 / 永久),**不**提供「可用总额」字段, + * 以「合计」与「可用」的口径散回后端或各自相加都会分叉。需要合计展示时统一走 + * {@link resolveProfileMudPointBalanceView}。 + */ +export type ProfileMudPointBalanceView = ProfileMudPointBalance & { + /** 每日免费 + 月度 + 永久;只用于展示,不代表「可用额度」。 */ + totalPoints: number; +}; + +export function resolveProfileMudPointBalanceView( + balance: ProfileMudPointBalance, +): ProfileMudPointBalanceView { + // 三个池子按 0 兜底:接口缺字段时宁可显示 0,也不能让合计变成 NaN。 + return { + ...balance, + dailyFreePoints: balance.dailyFreePoints ?? 0, + monthlyPoints: balance.monthlyPoints ?? 0, + permanentPoints: balance.permanentPoints ?? 0, + totalPoints: + (balance.dailyFreePoints ?? 0) + + (balance.monthlyPoints ?? 0) + + (balance.permanentPoints ?? 0), + }; +} + +/** + * 从任意余额形状推导展示用合计;三池缺省按 0 处理,整体为 `undefined` 时返回 `undefined`。 + */ +export function resolveProfileMudPointDisplayTotal( + balance: ProfileMudPointBalance | null | undefined, +): number | undefined { + if (!balance) { + return undefined; + } + return resolveProfileMudPointBalanceView(balance).totalPoints; +} + +/** + * 月度泥点的刷新时刻展示文案。 + * + * 账期锚点按北京时间定义(见 ADR「会员账期按开通日自然月」),所以展示也固定用北京时间, + * 不随设备时区漂移;无会员周期或时间非法时返回 `null`,由调用方给兜底说明。 + */ +export function formatMudPointResetAt( + value: string | null | undefined, +): string | null { + if (!value) { + return null; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return null; + } + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(parsed); + const lookup = (type: Intl.DateTimeFormatPartTypes) => + parts.find((part) => part.type === type)?.value ?? '00'; + return `${lookup('year')}-${lookup('month')}-${lookup('day')} ${lookup('hour')}:${lookup( + 'minute', + )}`; +} diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 6e21e9015..3d5dca1f6 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -201,15 +201,30 @@ pub struct ProfileDailyFreePointsResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub struct ProfileMudPointBalanceResponse { /// 每日免费泥点:北京业务日有效,跨日清零并重置。 + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub daily_free_points: u64, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub daily_free_reset_points: u64, pub daily_free_resets_at: String, /// 月度泥点:会员每期额度,到刷新时刻清零再发新额度。 + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub monthly_points: u64, pub monthly_resets_at: Option, /// 永久泥点:不过期,创作照常扣点。 + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub permanent_points: u64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 637214065..d19e12c93 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -665,7 +665,7 @@ pub mod runtime_profile_membership_snapshot_type; pub mod runtime_profile_membership_status_type; pub mod runtime_profile_membership_upgrade_quote_get_input_type; pub mod runtime_profile_membership_upgrade_quote_procedure_result_type; -pub mod runtime_profile_membership_upgrade_quote_record_type; +pub mod runtime_profile_membership_upgrade_quote_snapshot_type; pub mod runtime_profile_recharge_center_get_input_type; pub mod runtime_profile_recharge_center_procedure_result_type; pub mod runtime_profile_recharge_center_snapshot_type; @@ -1482,7 +1482,7 @@ pub use runtime_profile_membership_snapshot_type::RuntimeProfileMembershipSnapsh pub use runtime_profile_membership_status_type::RuntimeProfileMembershipStatus; pub use runtime_profile_membership_upgrade_quote_get_input_type::RuntimeProfileMembershipUpgradeQuoteGetInput; pub use runtime_profile_membership_upgrade_quote_procedure_result_type::RuntimeProfileMembershipUpgradeQuoteProcedureResult; -pub use runtime_profile_membership_upgrade_quote_record_type::RuntimeProfileMembershipUpgradeQuoteRecord; +pub use runtime_profile_membership_upgrade_quote_snapshot_type::RuntimeProfileMembershipUpgradeQuoteSnapshot; pub use runtime_profile_recharge_center_get_input_type::RuntimeProfileRechargeCenterGetInput; pub use runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult; pub use runtime_profile_recharge_center_snapshot_type::RuntimeProfileRechargeCenterSnapshot; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_procedure_result_type.rs index 92168b485..213059e5e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_procedure_result_type.rs @@ -4,13 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; -use super::runtime_profile_membership_upgrade_quote_record_type::RuntimeProfileMembershipUpgradeQuoteRecord; +use super::runtime_profile_membership_upgrade_quote_snapshot_type::RuntimeProfileMembershipUpgradeQuoteSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct RuntimeProfileMembershipUpgradeQuoteProcedureResult { pub ok: bool, - pub quote: Option, + pub quote: Option, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_record_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_snapshot_type.rs similarity index 85% rename from server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_record_type.rs rename to server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_snapshot_type.rs index 504002690..1ffc80dc3 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_record_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_snapshot_type.rs @@ -6,7 +6,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] -pub struct RuntimeProfileMembershipUpgradeQuoteRecord { +pub struct RuntimeProfileMembershipUpgradeQuoteSnapshot { pub current_plan: String, pub target_plan: String, pub cycle_kind: String, @@ -20,6 +20,6 @@ pub struct RuntimeProfileMembershipUpgradeQuoteRecord { pub remaining_full_months: u32, } -impl __sdk::InModule for RuntimeProfileMembershipUpgradeQuoteRecord { +impl __sdk::InModule for RuntimeProfileMembershipUpgradeQuoteSnapshot { type Module = super::RemoteModule; } From 3b9d645042ab65fcf882f45e997d34ec0ec51ff4 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 19:00:23 +0800 Subject: [PATCH 020/232] =?UTF-8?q?=E9=87=8D=E6=9E=84(=E9=92=B1=E5=8C=85):?= =?UTF-8?q?=20=E5=85=B1=E4=BA=AB=E7=BB=84=E4=BB=B6=E4=B8=8E=E9=92=B1?= =?UTF-8?q?=E5=8C=85=20store=20=E6=94=B9=E7=94=A8=E4=B8=89=E6=B1=A0?= =?UTF-8?q?=E4=BD=99=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createProfileWalletStore 的余额状态改为三池视图,去掉「响应只有 walletBalance 时兜底」的旧分支,缺少 mudPointBalance 一律算读取失败 - 泥点明细面板并列展示永久泥点 / 月度泥点 / 每日免费泥点,月度行带北京时间刷新时刻;「不限时泥点」标签改为「永久泥点」 - 充值弹层当前余额改用三池合计,不再读 walletBalance - 三池与首充赠送相关的 store、明细面板、充值弹层测试同步更新 Co-authored-by: Junie --- .../index.test.tsx | 17 ++- .../PlatformMudPointWalletEntry/index.tsx | 27 +++- .../index.test.tsx | 127 ++++++++---------- .../PlatformProfileRechargeModal/index.tsx | 6 +- .../stores/createProfileWalletStore.test.ts | 25 ++-- .../src/stores/createProfileWalletStore.ts | 26 ++-- 6 files changed, 118 insertions(+), 110 deletions(-) diff --git a/packages/shared/src/components/PlatformMudPointWalletEntry/index.test.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry/index.test.tsx index 6925cf4a3..982ddaeb6 100644 --- a/packages/shared/src/components/PlatformMudPointWalletEntry/index.test.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry/index.test.tsx @@ -8,10 +8,9 @@ import { formatMudPointCount } from '../../utils/format'; import { PlatformMudPointWalletEntry } from './index.tsx'; const breakdown = { - totalPoints: 207, permanentPoints: 100, - limitedPoints: 80, - limitedExpiresAt: '2026-07-06T16:00:00Z', + monthlyPoints: 80, + monthlyResetsAt: '2026-07-06T16:00:00Z', dailyFreePoints: 27, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-07-12T16:00:00Z', @@ -22,7 +21,7 @@ test('formats mud point counts consistently', () => { expect(formatMudPointCount(12_345, true)).toBe('1.2万'); }); -test('shows only permanent and daily free points in the shared wallet panel', async () => { +test('shows permanent, monthly and daily free points in the shared wallet panel', async () => { const user = userEvent.setup(); const onRequestDetails = vi.fn(); const onRecharge = vi.fn(); @@ -43,11 +42,15 @@ test('shows only permanent and daily free points in the shared wallet panel', as const details = screen.getByRole('dialog', { name: '泥点账户详情' }); expect(details.className).toContain('rounded-[1.25rem]'); - expect(within(details).getByText('不限时泥点')).toBeTruthy(); - expect(within(details).getByText('按量充值、兑换码获得')).toBeTruthy(); + expect(within(details).getByText('永久泥点')).toBeTruthy(); + expect(within(details).getByText('月度泥点')).toBeTruthy(); + expect(within(details).getByText('不过期,创作照常扣点')).toBeTruthy(); expect(within(details).getByText('100')).toBeTruthy(); + expect(within(details).getByText('80')).toBeTruthy(); + expect( + within(details).getByText('2026-07-07 00:00 清零并发放新额度'), + ).toBeTruthy(); expect(within(details).queryByText('限时泥点')).toBeNull(); - expect(within(details).queryByText('2026-07-07 到期')).toBeNull(); expect(within(details).getByText('每日免费泥点')).toBeTruthy(); expect(within(details).getByText('27')).toBeTruthy(); expect(within(details).getByText('每天重置为 20 泥点')).toBeTruthy(); diff --git a/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx index 69519586f..82b0d51e7 100644 --- a/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx @@ -11,10 +11,15 @@ import { import type { ProfileMudPointBalance } from '../../contracts/runtime'; import MUD_POINT_ICON_SRC from '../../icons/topbar-wallet.png'; import { formatMudPointCount } from '../../utils/format'; +import { + formatMudPointResetAt, + type ProfileMudPointBalanceView, + resolveProfileMudPointBalanceView, +} from '../../utils/mudPoints'; export type PlatformMudPointWalletEntryProps = { balance: number | null; - breakdown?: ProfileMudPointBalance | null; + breakdown?: ProfileMudPointBalance | ProfileMudPointBalanceView | null; isLoading?: boolean; error?: string | null; variant?: 'desktop' | 'mobile' | 'editor'; @@ -55,7 +60,7 @@ function MudPointBalanceRow({ export function PlatformMudPointWalletEntry({ balance, - breakdown, + breakdown: rawBreakdown, isLoading = false, error = null, variant = 'desktop', @@ -71,6 +76,9 @@ export function PlatformMudPointWalletEntry({ const closeTimerRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const isCompact = variant === 'mobile'; + const breakdown = rawBreakdown + ? resolveProfileMudPointBalanceView(rawBreakdown) + : null; const displayedBalance = balance ?? breakdown?.totalPoints ?? null; const balanceLabel = displayedBalance === null @@ -79,6 +87,7 @@ export function PlatformMudPointWalletEntry({ const exactBalanceLabel = displayedBalance === null ? '--' : formatMudPointCount(displayedBalance); const detailsRequestKey = `balance:${balance ?? breakdown?.totalPoints ?? 'unknown'}`; + const monthlyResetLabel = formatMudPointResetAt(breakdown?.monthlyResetsAt); const cancelPendingClose = useCallback(() => { if (closeTimerRef.current !== null) { @@ -272,11 +281,19 @@ export function PlatformMudPointWalletEntry({ {breakdown ? ( <> + - {/*TODO 限时泥点 is not enabled by now */} buildPointProduct({ - productId: 'points_180', - title: '180泥点', - priceCents: 1800, - pointsAmount: 180, - bonusPoints: 90, - badgeLabel: '首充加赠', - description: '首充加赠90泥点', + productId: `points_${pointsAmount}`, + title: `${pointsAmount}泥点`, + priceCents: pointsAmount * 10, + pointsAmount, + description: `${pointsAmount}泥点`, }), - buildPointProduct({ - productId: 'points_300', - title: '300泥点', - priceCents: 3000, - pointsAmount: 300, - bonusPoints: 150, - badgeLabel: '首充加赠', - description: '首充加赠150泥点', - }), - buildPointProduct({ - productId: 'points_680', - title: '680泥点', - priceCents: 6800, - pointsAmount: 680, - bonusPoints: 340, - badgeLabel: '首充加赠', - description: '首充加赠340泥点', - }), - ]; + ); } -function buildMembershipProduct(): ProfileRechargeProduct { +function buildMembershipPlan() { return { - ...buildPointProduct(), - productId: 'member_month', - title: '会员月卡', - priceCents: 2800, - kind: 'membership', - pointsAmount: 0, - durationDays: 30, - description: '会员月卡', - tier: 'month', - membershipPeriodDays: 30, + plan: 'starter' as const, + title: 'Starter', + rank: 1, + monthPriceCents: 3_900, + yearPriceCents: 39_000, + periodPoints: 400, + modelAccess: 'basic' as const, + concurrentJobLimit: 1, + sortOrder: 10, + enabled: true, }; } @@ -100,18 +89,17 @@ vi.mock('qrcode', () => ({ })); describe('PlatformProfileRechargeModal', () => { - test('renders the four point products and forwards the selected buy action', async () => { + test('renders the six point products and forwards the selected buy action', async () => { const user = userEvent.setup(); const onBuy = vi.fn(); render( { expect( within(dialog).getByRole('button', { name: '60泥点 ¥6 购买' }), ).toBeTruthy(); + expect( + within(dialog).getByRole('button', { name: '180泥点 ¥18 购买' }), + ).toBeTruthy(); expect( within(dialog).getByRole('button', { - name: '180泥点 +90泥点 ¥18 购买', + name: '300泥点 ¥30 购买', }), ).toBeTruthy(); expect( within(dialog).getByRole('button', { - name: '300泥点 +150泥点 ¥30 购买', + name: '680泥点 ¥68 购买', }), ).toBeTruthy(); + expect(within(dialog).queryAllByText('首充加赠')).toHaveLength(0); + expect( + within(dialog).getByRole('button', { name: '1,280泥点 ¥128 购买' }), + ).toBeTruthy(); expect( - within(dialog).getByRole('button', { - name: '680泥点 +340泥点 ¥68 购买', - }), + within(dialog).getByRole('button', { name: '3,280泥点 ¥328 购买' }), ).toBeTruthy(); - expect(within(dialog).getAllByText('首充加赠')).toHaveLength(3); expect(within(dialog).queryByRole('tablist')).toBeNull(); - expect(within(dialog).queryByText('会员月卡')).toBeNull(); + expect(within(dialog).queryByText('Starter')).toBeNull(); await user.click( - within(dialog).getByRole('button', { - name: '180泥点 +90泥点 ¥18 购买', - }), + within(dialog).getByRole('button', { name: '180泥点 ¥18 购买' }), ); expect(onBuy).toHaveBeenCalledWith( expect.objectContaining({ productId: 'points_180' }), @@ -165,11 +155,10 @@ describe('PlatformProfileRechargeModal', () => { render( { ); expect(screen.getByText('暂无可购买套餐')).toBeTruthy(); - expect(screen.queryByText('会员月卡')).toBeNull(); + expect(screen.queryByText('Starter')).toBeNull(); expect(screen.queryByRole('tablist')).toBeNull(); }); @@ -198,11 +187,10 @@ describe('PlatformProfileRechargeModal', () => { render( { render( diff --git a/packages/shared/src/stores/createProfileWalletStore.test.ts b/packages/shared/src/stores/createProfileWalletStore.test.ts index c216c73dd..609132cdc 100644 --- a/packages/shared/src/stores/createProfileWalletStore.test.ts +++ b/packages/shared/src/stores/createProfileWalletStore.test.ts @@ -8,10 +8,9 @@ import { createProfileWalletStore } from './createProfileWalletStore'; function balance(totalPoints: number): ProfileMudPointBalance { return { - totalPoints, permanentPoints: totalPoints, - limitedPoints: 0, - limitedExpiresAt: null, + monthlyPoints: 0, + monthlyResetsAt: null, dailyFreePoints: 0, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', @@ -254,31 +253,29 @@ describe('createProfileWalletStore', () => { expect(store.getState().mudPointBalance?.totalPoints).toBe(100); }); - test('retains a legacy total without inventing a balance breakdown', async () => { + test('treats a center response without a balance breakdown as an error', async () => { const store = createProfileWalletStore({ - getRechargeCenter: vi.fn().mockResolvedValue({ - walletBalance: 37, - } as ProfileRechargeCenterResponse), + getRechargeCenter: vi + .fn() + .mockResolvedValue({} as ProfileRechargeCenterResponse), }); store.getState().setWalletOwner('user-a'); await store.getState().onWalletBalanceMayHaveChanged(); expect(store.getState()).toMatchObject({ - legacyWalletBalance: 37, + legacyWalletBalance: null, mudPointBalance: null, mudPointBalanceStatus: 'error', mudPointBalanceError: '泥点明细读取失败', }); }); - test('clears a stale balance breakdown when a newer refresh only has a legacy total', async () => { + test('keeps the last known good breakdown when a newer refresh fails', async () => { const getRechargeCenter = vi .fn() .mockResolvedValueOnce(center(90)) - .mockResolvedValueOnce({ - walletBalance: 37, - } as ProfileRechargeCenterResponse); + .mockRejectedValueOnce(new Error('network down')); const store = createProfileWalletStore({ getRechargeCenter }); store.getState().setWalletOwner('user-a'); @@ -286,11 +283,11 @@ describe('createProfileWalletStore', () => { await store.getState().onWalletBalanceMayHaveChanged(); expect(store.getState()).toMatchObject({ - legacyWalletBalance: 37, - mudPointBalance: null, + legacyWalletBalance: 90, mudPointBalanceStatus: 'error', mudPointBalanceError: '泥点明细读取失败', }); + expect(store.getState().mudPointBalance?.permanentPoints).toBe(90); }); test('applies an owner-scoped legacy mutation snapshot atomically', () => { diff --git a/packages/shared/src/stores/createProfileWalletStore.ts b/packages/shared/src/stores/createProfileWalletStore.ts index 4cace6e85..2dd46c251 100644 --- a/packages/shared/src/stores/createProfileWalletStore.ts +++ b/packages/shared/src/stores/createProfileWalletStore.ts @@ -4,6 +4,10 @@ import type { ProfileMudPointBalance, ProfileRechargeCenterResponse, } from '../contracts/runtime'; +import { + type ProfileMudPointBalanceView, + resolveProfileMudPointBalanceView, +} from '../utils/mudPoints'; export type MudPointBalanceStatus = 'idle' | 'loading' | 'ready' | 'error'; @@ -22,7 +26,7 @@ export type ProfileWalletBalanceSnapshot = Readonly<{ export type ProfileWalletStore = { ownerUserId: string | null; - mudPointBalance: ProfileMudPointBalance | null; + mudPointBalance: ProfileMudPointBalanceView | null; legacyWalletBalance: number | null; mudPointBalanceStatus: MudPointBalanceStatus; mudPointBalanceError: string; @@ -32,7 +36,7 @@ export type ProfileWalletStore = { ) => ProfileWalletBalanceSnapshot | null; applyWalletBalanceSnapshot: ( snapshot: ProfileWalletBalanceSnapshot, - balance: ProfileMudPointBalance, + balance: ProfileMudPointBalance | ProfileMudPointBalanceView, ) => boolean; applyLegacyWalletBalanceSnapshot: ( snapshot: ProfileWalletBalanceSnapshot, @@ -119,9 +123,10 @@ export function createProfileWalletStore( snapshot.operationSequence, ); settledRefreshVersion = snapshot.invalidationVersion; + const mudPointBalance = resolveProfileMudPointBalanceView(balance); set({ - mudPointBalance: balance, - legacyWalletBalance: balance.totalPoints, + mudPointBalance, + legacyWalletBalance: mudPointBalance.totalPoints, mudPointBalanceStatus: 'ready', mudPointBalanceError: '', }); @@ -181,19 +186,16 @@ export function createProfileWalletStore( continue; } if (!center.mudPointBalance) { - if (Number.isFinite(center.walletBalance)) { - set({ - mudPointBalance: null, - legacyWalletBalance: center.walletBalance, - }); - } throw new Error('充值中心响应缺少泥点余额'); } settledRefreshVersion = requestedVersion; + const mudPointBalance = resolveProfileMudPointBalanceView( + center.mudPointBalance, + ); set({ - mudPointBalance: center.mudPointBalance, - legacyWalletBalance: center.mudPointBalance.totalPoints, + mudPointBalance, + legacyWalletBalance: mudPointBalance.totalPoints, mudPointBalanceStatus: 'ready', mudPointBalanceError: '', }); From 144068187cee2f79e9aedf1f4ed8edc4cbe9c2e7 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 19:03:30 +0800 Subject: [PATCH 021/232] =?UTF-8?q?=E9=87=8D=E6=9E=84(=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E5=85=A5=E5=8F=A3):=20=E5=B9=B3=E5=8F=B0=E4=BE=A7=E4=B8=8E=20A?= =?UTF-8?q?GC=20=E5=A3=B3=E6=94=B9=E7=94=A8=E4=B8=89=E6=B1=A0=E4=BD=99?= =?UTF-8?q?=E9=A2=9D=EF=BC=8C=E9=80=80=E5=BD=B9=20legacy=20=E6=80=BB?= =?UTF-8?q?=E9=A2=9D=E5=86=99=E5=85=A5=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 平台入口与钱包 store 不再从充值中心响应读 walletBalance,合计改为按三池派生 - AGC 账户钱包同步:只透传 mudPointBalance,删掉 RechargeContent 的 walletBalance 排除项 - 删除已无生产调用方的 applyLegacyWalletBalanceSnapshot 与对应测试;明细读取失败不再伪造「只有总额」的中间态 - 图片编辑器与平台入口测试 fixture 改为三池形状,钱包面板断言改用「永久泥点 / 月度泥点」 Co-authored-by: Junie --- apps/ai-game-creator-shell/src/app/types.ts | 2 +- .../features/app-shell/useAccountWallet.ts | 19 ++------ .../tests/appSurface/home.suite.ts | 38 ++++++--------- .../tests/walletStore.test.ts | 43 +++++++---------- .../stores/createProfileWalletStore.test.ts | 23 ++-------- .../src/stores/createProfileWalletStore.ts | 32 +------------ .../ImageCanvasEditorView.test.tsx | 46 ++++++++----------- .../ImageCanvasTopbarView.test.tsx | 12 ++--- .../PlatformActiveProfileView.test.tsx | 4 +- .../PlatformActiveProfileView.tsx | 6 +-- .../PlatformEntryActiveFlowShell.test.tsx | 38 +++++++++------ .../PlatformEntryActiveFlowShell.tsx | 5 +- ...mProfileCenterController.recharge.test.tsx | 21 +++++---- ...sePlatformProfileCenterController.test.tsx | 15 +++--- .../usePlatformProfileCenterController.ts | 2 - src/stores/usePlatformWalletStore.test.tsx | 6 +-- 16 files changed, 116 insertions(+), 196 deletions(-) diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 5d0c1e99a..0cc1c072a 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -12,7 +12,7 @@ import type { HomeCreationType, HomeDraft } from '../view/home'; export type RechargeContent = Omit< ProfileRechargeCenterResponse, - 'walletBalance' | 'mudPointBalance' + 'mudPointBalance' >; export type AgentChatInteractionMode = 'run' | 'chat' | 'goal'; export type AgentBackgroundSubmitMode = 'steer' | 'queue'; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts index 1d6f0a339..c88492ff6 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts @@ -26,7 +26,6 @@ export function useAccountWallet(currentUserId: string) { setWalletOwner, captureWalletBalanceSnapshot, applyWalletBalanceSnapshot, - applyLegacyWalletBalanceSnapshot, onWalletBalanceMayHaveChanged, resetWalletBalance, } = useWalletStore(); @@ -76,8 +75,6 @@ export function useAccountWallet(currentUserId: string) { const visibleMudPointBalanceError = walletOwnerMatchesCurrentUser ? mudPointBalanceError : ''; - const visibleWalletBalance = - visibleMudPointBalance?.totalPoints ?? visibleLegacyWalletBalance; useLayoutEffect(() => { currentUserIdRef.current = currentUserId; @@ -165,16 +162,9 @@ export function useAccountWallet(currentUserId: string) { walletSnapshot: ReturnType, center: ProfileRechargeCenterResponse, ) { - const { - walletBalance, - mudPointBalance: nextMudPointBalance, - ...content - } = center; + const { mudPointBalance: nextMudPointBalance, ...content } = center; if (nextMudPointBalance && walletSnapshot) { applyWalletBalanceSnapshot(walletSnapshot, nextMudPointBalance); - } else if (walletSnapshot && Number.isFinite(walletBalance)) { - applyLegacyWalletBalanceSnapshot(walletSnapshot, walletBalance); - void onWalletBalanceMayHaveChanged(); } else { void onWalletBalanceMayHaveChanged(); } @@ -400,13 +390,10 @@ export function useAccountWallet(currentUserId: string) { walletLedgerError: walletUiIsVisible ? walletLedgerError : null, rechargeOpen: walletUiIsVisible && rechargeOpen, rechargeModalCenter: - rechargeContent && visibleWalletBalance !== null + rechargeContent && visibleMudPointBalance !== null ? { ...rechargeContent, - walletBalance: visibleWalletBalance, - ...(visibleMudPointBalance - ? { mudPointBalance: visibleMudPointBalance } - : {}), + mudPointBalance: visibleMudPointBalance, } : null, rechargeLoading: walletUiIsVisible && rechargeLoading, diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 912be89a7..b8f71f910 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -767,15 +767,13 @@ export function registerClientHomeTests() { it('starts from the client home and opens a project in the same window', async () => { // 账户与钱包的远端真相在 Rust typed command:渲染层只消费投影,不再自己发请求。 const rechargeCenter = { - walletBalance: 40, mudPointBalance: { - totalPoints: 40, - permanentPoints: 20, - limitedPoints: 0, - limitedExpiresAt: null, dailyFreePoints: 20, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-07-18T00:00:00.000Z', + monthlyPoints: 0, + monthlyResetsAt: null, + permanentPoints: 20, }, products: [], membership: null, @@ -887,19 +885,17 @@ export function registerClientHomeTests() { it('opens the shared recharge modal from the wallet and sidebar entries', async () => { const rechargeCenter = { - walletBalance: 120, mudPointBalance: { - totalPoints: 120, - permanentPoints: 100, - limitedPoints: 20, - limitedExpiresAt: '2026-08-01T00:00:00Z', dailyFreePoints: 0, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-07-18T00:00:00Z', + monthlyPoints: 20, + monthlyResetsAt: '2026-08-01T00:00:00Z', + permanentPoints: 100, }, membership: { - status: 'inactive', - tier: 'normal', + status: 'normal', + plan: 'normal', startedAt: null, expiresAt: null, updatedAt: null, @@ -907,7 +903,9 @@ export function registerClientHomeTests() { cycleResetsAt: null, cycleGrantedPoints: 0, cycleRemainingPoints: 0, - cyclePeriodDays: 0, + cycleIndex: 1, + cycleCount: 1, + cycleKind: 'monthly', }, pointProducts: [ { @@ -920,15 +918,9 @@ export function registerClientHomeTests() { durationDays: 0, badgeLabel: '', description: '', - tier: 'normal', - membershipPeriodPoints: 0, - membershipPeriodDays: 0, - membershipQueueLimit: 0, - membershipDiscountBps: 0, }, ], - membershipProducts: [], - benefits: [], + membershipPlans: [], latestOrder: null, hasPointsRecharged: true, }; @@ -939,10 +931,9 @@ export function registerClientHomeTests() { rechargePaid ? { ...rechargeCenter, - walletBalance: 180, mudPointBalance: { ...rechargeCenter.mudPointBalance, - totalPoints: 180, + monthlyPoints: 20, permanentPoints: 160, }, } @@ -1003,10 +994,9 @@ export function registerClientHomeTests() { }, center: { ...rechargeCenter, - walletBalance: 180, mudPointBalance: { ...rechargeCenter.mudPointBalance, - totalPoints: 180, + monthlyPoints: 20, permanentPoints: 160, }, }, diff --git a/apps/ai-game-creator-shell/tests/walletStore.test.ts b/apps/ai-game-creator-shell/tests/walletStore.test.ts index 984fb5cff..8062796dc 100644 --- a/apps/ai-game-creator-shell/tests/walletStore.test.ts +++ b/apps/ai-game-creator-shell/tests/walletStore.test.ts @@ -19,10 +19,9 @@ import { useWalletStore } from '../src/stores/useWalletStore'; function detailedBalance(totalPoints: number) { return { - totalPoints, permanentPoints: Math.max(0, totalPoints - 20), - limitedPoints: 20, - limitedExpiresAt: '2026-08-01T00:00:00Z', + monthlyPoints: Math.min(20, totalPoints), + monthlyResetsAt: '2026-08-01T00:00:00Z', dailyFreePoints: 0, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-07-18T00:00:00Z', @@ -39,7 +38,6 @@ describe('useWalletStore', () => { test('keeps mudPointBalance as the authoritative detailed balance state', async () => { const mudPointBalance = detailedBalance(120); clientApi.getClientProfileRechargeCenter.mockResolvedValue({ - walletBalance: 120, mudPointBalance, }); const { result } = renderHook(() => useWalletStore()); @@ -48,8 +46,7 @@ describe('useWalletStore', () => { await result.current.onWalletBalanceMayHaveChanged(); }); - expect(result.current.mudPointBalance).toEqual(mudPointBalance); - expect(result.current.legacyWalletBalance).toBe(120); + expect(result.current.mudPointBalance).toMatchObject(mudPointBalance); expect(result.current.mudPointBalanceStatus).toBe('ready'); expect(result.current.mudPointBalanceError).toBe(''); expect(Object.keys(result.current).sort()).toEqual([ @@ -81,7 +78,9 @@ describe('useWalletStore', () => { .getState() .applyWalletBalanceSnapshot(snapshot!, mudPointBalance); - expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance); + expect(useWalletStore.getState().mudPointBalance).toMatchObject( + mudPointBalance, + ); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('ready'); expect(useWalletStore.getState().mudPointBalanceError).toBe(''); }); @@ -106,20 +105,20 @@ describe('useWalletStore', () => { rejectRequest?.(new Error('过期请求失败')); await refresh; - expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance); + expect(useWalletStore.getState().mudPointBalance).toMatchObject( + mudPointBalance, + ); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('ready'); expect(useWalletStore.getState().mudPointBalanceError).toBe(''); }); test('rejects a response that omits mudPointBalance instead of inventing a breakdown', async () => { - clientApi.getClientProfileRechargeCenter.mockResolvedValue({ - walletBalance: 120, - }); + clientApi.getClientProfileRechargeCenter.mockResolvedValue({}); await useWalletStore.getState().onWalletBalanceMayHaveChanged(); expect(useWalletStore.getState().mudPointBalance).toBeNull(); - expect(useWalletStore.getState().legacyWalletBalance).toBe(120); + expect(useWalletStore.getState().legacyWalletBalance).toBeNull(); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('error'); expect(useWalletStore.getState().mudPointBalanceError).toBe( '泥点明细读取失败', @@ -129,13 +128,14 @@ describe('useWalletStore', () => { test('uses mudPointBalance from recharge-center as the authoritative snapshot', async () => { const mudPointBalance = detailedBalance(180); clientApi.getClientProfileRechargeCenter.mockResolvedValue({ - walletBalance: 999, mudPointBalance, }); await useWalletStore.getState().onWalletBalanceMayHaveChanged(); - expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance); + expect(useWalletStore.getState().mudPointBalance).toMatchObject( + mudPointBalance, + ); }); test('discards an outdated response and performs one trailing refresh', async () => { @@ -148,7 +148,6 @@ describe('useWalletStore', () => { }), ) .mockResolvedValueOnce({ - walletBalance: 80, mudPointBalance: detailedBalance(80), }); @@ -159,7 +158,6 @@ describe('useWalletStore', () => { .getState() .onWalletBalanceMayHaveChanged(); resolveFirst?.({ - walletBalance: 120, mudPointBalance: detailedBalance(120), }); await Promise.all([firstRefresh, trailingRefresh]); @@ -170,7 +168,6 @@ describe('useWalletStore', () => { test('retains the last balance when a refresh fails', async () => { clientApi.getClientProfileRechargeCenter.mockResolvedValueOnce({ - walletBalance: 90, mudPointBalance: detailedBalance(90), }); await useWalletStore.getState().onWalletBalanceMayHaveChanged(); @@ -199,7 +196,6 @@ describe('useWalletStore', () => { const refresh = useWalletStore.getState().onWalletBalanceMayHaveChanged(); useWalletStore.getState().resetWalletBalance(); resolveRequest?.({ - walletBalance: 75, mudPointBalance: detailedBalance(75), }); await refresh; @@ -248,7 +244,6 @@ describe('useWalletStore', () => { test('binds the account owner and refreshes again when the window regains focus', async () => { clientApi.getClientProfileRechargeCenter.mockResolvedValue({ - walletBalance: 18, mudPointBalance: detailedBalance(18), }); @@ -267,16 +262,14 @@ describe('useWalletStore', () => { }); }); - test('keeps a successful legacy recharge total when the breakdown refresh fails', async () => { + test('keeps the last known good breakdown when the recharge center omits it', async () => { clientApi.getClientProfileRechargeCenter.mockResolvedValueOnce({ - walletBalance: 90, mudPointBalance: detailedBalance(90), }); const { result } = renderHook(() => useAccountWallet('user-a')); await act(async () => undefined); clientApi.getClientProfileRechargeCenter .mockResolvedValueOnce({ - walletBalance: 37, membership: null, pointProducts: [], membershipProducts: [], @@ -291,14 +284,13 @@ describe('useWalletStore', () => { await Promise.resolve(); }); - expect(result.current.legacyWalletBalance).toBe(37); - expect(result.current.mudPointBalance).toBeNull(); + expect(result.current.mudPointBalance?.totalPoints).toBe(90); + expect(result.current.mudPointBalanceStatus).toBe('error'); }); test('keeps ledger loading independent from the recharge lifecycle', async () => { let resolveLedger!: (value: { entries: [] }) => void; clientApi.getClientProfileRechargeCenter.mockResolvedValue({ - walletBalance: 18, mudPointBalance: detailedBalance(18), }); clientApi.getClientProfileWalletLedger.mockImplementation( @@ -326,7 +318,6 @@ describe('useWalletStore', () => { test('hides account-owned dialogs and data when the account changes', async () => { clientApi.getClientProfileRechargeCenter.mockResolvedValue({ - walletBalance: 18, mudPointBalance: detailedBalance(18), }); clientApi.getClientProfileWalletLedger.mockResolvedValue({ entries: [] }); diff --git a/packages/shared/src/stores/createProfileWalletStore.test.ts b/packages/shared/src/stores/createProfileWalletStore.test.ts index 609132cdc..88c5da148 100644 --- a/packages/shared/src/stores/createProfileWalletStore.test.ts +++ b/packages/shared/src/stores/createProfileWalletStore.test.ts @@ -290,28 +290,15 @@ describe('createProfileWalletStore', () => { expect(store.getState().mudPointBalance?.permanentPoints).toBe(90); }); - test('applies an owner-scoped legacy mutation snapshot atomically', () => { + test('rejects a snapshot from another owner', () => { const store = createProfileWalletStore({ getRechargeCenter: vi.fn() }); store.getState().setWalletOwner('user-a'); - const structuredSnapshot = store - .getState() - .captureWalletBalanceSnapshot('user-a'); - store - .getState() - .applyWalletBalanceSnapshot(structuredSnapshot!, balance(90)); - const legacySnapshot = store - .getState() - .captureWalletBalanceSnapshot('user-a'); + const snapshot = store.getState().captureWalletBalanceSnapshot('user-b'); expect( - store.getState().applyLegacyWalletBalanceSnapshot(legacySnapshot!, 37), - ).toBe(true); - expect(store.getState()).toMatchObject({ - legacyWalletBalance: 37, - mudPointBalance: null, - mudPointBalanceStatus: 'ready', - mudPointBalanceError: '', - }); + store.getState().applyWalletBalanceSnapshot(snapshot!, balance(37)), + ).toBe(false); + expect(store.getState().mudPointBalance).toBeNull(); }); test('rejects a snapshot after switching away from and back to the same owner', () => { diff --git a/packages/shared/src/stores/createProfileWalletStore.ts b/packages/shared/src/stores/createProfileWalletStore.ts index 2dd46c251..a2ec1eeeb 100644 --- a/packages/shared/src/stores/createProfileWalletStore.ts +++ b/packages/shared/src/stores/createProfileWalletStore.ts @@ -36,11 +36,7 @@ export type ProfileWalletStore = { ) => ProfileWalletBalanceSnapshot | null; applyWalletBalanceSnapshot: ( snapshot: ProfileWalletBalanceSnapshot, - balance: ProfileMudPointBalance | ProfileMudPointBalanceView, - ) => boolean; - applyLegacyWalletBalanceSnapshot: ( - snapshot: ProfileWalletBalanceSnapshot, - balance: number, + balance: ProfileMudPointBalance, ) => boolean; onWalletBalanceMayHaveChanged: () => Promise; resetWalletBalance: () => void; @@ -132,32 +128,6 @@ export function createProfileWalletStore( }); return true; }, - applyLegacyWalletBalanceSnapshot: (snapshot, balance) => { - if ( - !Number.isFinite(balance) || - get().ownerUserId !== snapshot.ownerUserId || - ownerVersion !== snapshot.ownerVersion || - snapshot.invalidationVersion < refreshVersion || - snapshot.invalidationVersion < settledRefreshVersion || - snapshot.operationSequence < latestAppliedOperationSequence - ) { - return false; - } - - invalidateActiveRefresh(); - latestAppliedOperationSequence = Math.max( - latestAppliedOperationSequence, - snapshot.operationSequence, - ); - settledRefreshVersion = snapshot.invalidationVersion; - set({ - mudPointBalance: null, - legacyWalletBalance: balance, - mudPointBalanceStatus: 'ready', - mudPointBalanceError: '', - }); - return true; - }, onWalletBalanceMayHaveChanged: () => { refreshVersion += 1; if (activeRefresh) { diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 92b947c88..0d10fcf7f 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -339,19 +339,18 @@ describe('ImageCanvasEditorView', () => { updatedAt: null, }); getPlatformProfileRechargeCenterMock.mockResolvedValue({ - walletBalance: 1234, mudPointBalance: { totalPoints: 1234, permanentPoints: 1000, - limitedPoints: 214, - limitedExpiresAt: '2026-07-31T16:00:00Z', + monthlyPoints: 214, + monthlyResetsAt: '2026-07-31T16:00:00Z', dailyFreePoints: 20, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-07-12T16:00:00Z', }, membership: { status: 'normal', - tier: 'normal', + plan: 'normal', startedAt: null, expiresAt: null, updatedAt: null, @@ -359,11 +358,12 @@ describe('ImageCanvasEditorView', () => { cycleResetsAt: null, cycleGrantedPoints: 0, cycleRemainingPoints: 0, - cyclePeriodDays: 30, + cycleIndex: 1, + cycleCount: 1, + cycleKind: 'monthly', }, pointProducts: [], - membershipProducts: [], - benefits: [], + membershipPlans: [], latestOrder: null, hasPointsRecharged: false, }); @@ -635,10 +635,9 @@ describe('ImageCanvasEditorView', () => { usePlatformWalletStore .getState() .applyWalletBalanceSnapshot(walletSnapshot!, { - totalPoints: 1234, permanentPoints: 1000, - limitedPoints: 214, - limitedExpiresAt: '2026-07-31T16:00:00Z', + monthlyPoints: 214, + monthlyResetsAt: '2026-07-31T16:00:00Z', dailyFreePoints: 20, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-07-12T16:00:00Z', @@ -703,18 +702,12 @@ describe('ImageCanvasEditorView', () => { expect(openAccountModal).toHaveBeenCalledTimes(1); }); - it('shows the owner-matched legacy wallet total without inventing breakdown rows', async () => { + it('shows the breakdown read failure without inventing rows', async () => { const user = userEvent.setup(); usePlatformWalletStore.getState().setWalletOwner('user-1'); - const walletSnapshot = usePlatformWalletStore - .getState() - .captureWalletBalanceSnapshot('user-1'); - usePlatformWalletStore - .getState() - .applyLegacyWalletBalanceSnapshot(walletSnapshot!, 37); - getPlatformProfileRechargeCenterMock.mockResolvedValue({ - walletBalance: 37, - }); + getPlatformProfileRechargeCenterMock.mockRejectedValue( + new Error('network down'), + ); render( { ); const walletButton = await screen.findByRole('button', { - name: '泥点 37', + name: '泥点 --', }); await user.hover(walletButton); @@ -745,7 +738,7 @@ describe('ImageCanvasEditorView', () => { }); expect(within(details).getByText('泥点明细读取失败')).toBeTruthy(); expect(within(details).queryByText('充值中心响应缺少泥点余额')).toBeNull(); - expect(within(details).queryByText('不限时泥点')).toBeNull(); + expect(within(details).queryByText('永久泥点')).toBeNull(); expect(within(details).queryByText('每日免费泥点')).toBeNull(); }); @@ -786,10 +779,9 @@ describe('ImageCanvasEditorView', () => { usePlatformWalletStore .getState() .applyWalletBalanceSnapshot(walletSnapshot!, { - totalPoints: 1234, permanentPoints: 1000, - limitedPoints: 0, - limitedExpiresAt: null, + monthlyPoints: 0, + monthlyResetsAt: null, dailyFreePoints: 234, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-07-12T16:00:00Z', @@ -823,8 +815,8 @@ describe('ImageCanvasEditorView', () => { const details = await screen.findByRole('dialog', { name: '泥点账户详情', }); - expect(within(details).getByText('不限时泥点')).toBeTruthy(); - expect(within(details).queryByText('限时泥点')).toBeNull(); + expect(within(details).getByText('永久泥点')).toBeTruthy(); + expect(within(details).getByText('月度泥点')).toBeTruthy(); expect(within(details).getByText('每日免费泥点')).toBeTruthy(); await user.click(within(details).getByRole('button', { name: '使用详情' })); diff --git a/src/components/image-editor/ImageCanvasTopbarView.test.tsx b/src/components/image-editor/ImageCanvasTopbarView.test.tsx index d8b2a1ff1..4a4c511df 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.test.tsx @@ -17,8 +17,8 @@ import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts'; const walletBreakdown = { totalPoints: 1_234, permanentPoints: 1_000, - limitedPoints: 214, - limitedExpiresAt: '2026-07-31T16:00:00Z', + monthlyPoints: 214, + monthlyResetsAt: '2026-07-31T16:00:00Z', dailyFreePoints: 20, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-07-12T16:00:00Z', @@ -105,10 +105,6 @@ describe('ImageCanvasTopbarView', () => { const user = userEvent.setup(); const props = renderTopbar({ walletBalance: 12_345, - walletBreakdown: { - ...walletBreakdown, - totalPoints: 12_345, - }, }); const walletChip = screen.getByRole('button', { name: '泥点 12,345', @@ -118,8 +114,8 @@ describe('ImageCanvasTopbarView', () => { await user.hover(walletChip); const details = screen.getByRole('dialog', { name: '泥点账户详情' }); - expect(within(details).getByText('不限时泥点')).toBeTruthy(); - expect(within(details).queryByText('限时泥点')).toBeNull(); + expect(within(details).getByText('永久泥点')).toBeTruthy(); + expect(within(details).getByText('月度泥点')).toBeTruthy(); expect(within(details).getByText('每日免费泥点')).toBeTruthy(); await user.click(within(details).getByRole('button', { name: '使用详情' })); diff --git a/src/components/platform-entry/PlatformActiveProfileView.test.tsx b/src/components/platform-entry/PlatformActiveProfileView.test.tsx index acf9d4934..9b0efa1d4 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.test.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.test.tsx @@ -77,8 +77,8 @@ describe('PlatformActiveProfileView', () => { mudPointBalance={{ totalPoints: 108, permanentPoints: 88, - limitedPoints: 0, - limitedExpiresAt: null, + monthlyPoints: 0, + monthlyResetsAt: null, dailyFreePoints: 20, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', diff --git a/src/components/platform-entry/PlatformActiveProfileView.tsx b/src/components/platform-entry/PlatformActiveProfileView.tsx index 8b431a2aa..03ca20518 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.tsx @@ -15,7 +15,7 @@ import { useCallback, useRef, useState } from 'react'; import type { AuthUser, ProfileDashboardSummary, - ProfileMudPointBalance, + ProfileMudPointBalanceView, } from '@/packages/shared/src'; import profileClockImage from '../../../media/profile/_Image (1).png'; @@ -66,7 +66,7 @@ type PlatformActiveProfileViewProps = { isLoadingDashboard: boolean; isLoadingWalletBalance: boolean; legacyWalletBalance: number | null; - mudPointBalance: ProfileMudPointBalance | null; + mudPointBalance: ProfileMudPointBalanceView | null; onLogin: () => void; onOpenApiKeys: () => void; onOpenCommunity: () => void; @@ -248,7 +248,7 @@ function formatDashboardCount(value: number) { } function formatWalletBalance( - balance: ProfileMudPointBalance | null, + balance: ProfileMudPointBalanceView | null, legacyBalance: number | null, isLoading: boolean, ) { diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index aff31a85a..e71e02398 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -41,7 +41,13 @@ const profileClientMock = vi.hoisted(() => ({ const profileCenterMock = vi.hoisted(() => ({ isWalletLedgerOpen: false, - rechargeCenter: null as { walletBalance: number } | null, + rechargeCenter: null as { + mudPointBalance?: { + dailyFreePoints: number; + monthlyPoints: number; + permanentPoints: number; + }; + } | null, })); const gameDistributionMock = vi.hoisted(() => ({ @@ -245,12 +251,10 @@ describe('PlatformEntryActiveFlowShell', () => { updatedAt: null, }); profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({ - walletBalance: 888, mudPointBalance: { - totalPoints: 207, permanentPoints: 180, - limitedPoints: 7, - limitedExpiresAt: '2026-08-31T16:00:00Z', + monthlyPoints: 7, + monthlyResetsAt: '2026-08-31T16:00:00Z', dailyFreePoints: 20, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', @@ -294,7 +298,14 @@ describe('PlatformEntryActiveFlowShell', () => { authUiMock.value.canAccessProtectedData = true; profileClientMock.getPlatformProfileDashboard.mockResolvedValue(null); profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({ - walletBalance: 37, + mudPointBalance: { + permanentPoints: 37, + monthlyPoints: 0, + monthlyResetsAt: null, + dailyFreePoints: 0, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', + }, }); profileCenterMock.isWalletLedgerOpen = true; @@ -338,10 +349,9 @@ describe('PlatformEntryActiveFlowShell', () => { profileClientMock.getPlatformProfileDashboard.mockResolvedValue(null); profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({ mudPointBalance: { - totalPoints: 66, permanentPoints: 66, - limitedPoints: 0, - limitedExpiresAt: null, + monthlyPoints: 0, + monthlyResetsAt: null, dailyFreePoints: 0, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', @@ -379,10 +389,9 @@ describe('PlatformEntryActiveFlowShell', () => { }); resolveNextOwner({ mudPointBalance: { - totalPoints: 67, permanentPoints: 67, - limitedPoints: 0, - limitedExpiresAt: null, + monthlyPoints: 0, + monthlyResetsAt: null, dailyFreePoints: 0, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', @@ -406,10 +415,9 @@ describe('PlatformEntryActiveFlowShell', () => { profileClientMock.getPlatformProfileDashboard.mockResolvedValue(null); profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({ mudPointBalance: { - totalPoints: 10, permanentPoints: 10, - limitedPoints: 0, - limitedExpiresAt: null, + monthlyPoints: 0, + monthlyResetsAt: null, dailyFreePoints: 0, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index 26007e677..a2af89877 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -22,6 +22,7 @@ import { PlatformProfileRechargeModal } from '../../../packages/shared/src/compo import { PlatformProfileReferralModal } from '../../../packages/shared/src/components/PlatformProfileReferralModal'; import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/components/PlatformProfileWalletLedgerModal'; import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime'; +import { resolveProfileMudPointDisplayTotal } from '../../../packages/shared/src/utils/mudPoints'; import { hasAppHistoryBackEntry, pushAppHistoryPath, @@ -376,7 +377,9 @@ export function PlatformEntryFlowShellImpl({ }); const legacyWalletBalance = walletOwnerMatchesCurrentUser ? (storedLegacyWalletBalance ?? - profileCenter.rechargeCenter?.walletBalance ?? + resolveProfileMudPointDisplayTotal( + profileCenter.rechargeCenter?.mudPointBalance, + ) ?? null) : null; diff --git a/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx index 5b222cc95..7cb0803e5 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx +++ b/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx @@ -32,10 +32,9 @@ function deferred() { function mudPointBalance(totalPoints: number) { return { - totalPoints, permanentPoints: totalPoints, - limitedPoints: 0, - limitedExpiresAt: null, + monthlyPoints: 0, + monthlyResetsAt: null, dailyFreePoints: 0, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-08-07T00:00:00+08:00', @@ -52,7 +51,6 @@ function nativeRechargeResponse(totalPoints = 80, orderId = 'order-a') { expirationCheckedAt: null, }, center: { - walletBalance: totalPoints, mudPointBalance: mudPointBalance(totalPoints), }, wechatNativePayment: { @@ -76,14 +74,13 @@ describe('usePlatformProfileCenterController recharge fallback', () => { ); }); - test('keeps the response legacy balance when no shared snapshot exists', async () => { + test('keeps the response three-pool balance when no shared snapshot exists', async () => { usePlatformWalletStore.getState().setWalletOwner('user-a'); profileClientMocks.getPlatformProfileRechargeCenter.mockResolvedValue({ - walletBalance: 37, + mudPointBalance: mudPointBalance(37), membership: null, pointProducts: [], - membershipProducts: [], - benefits: [], + membershipPlans: [], latestOrder: null, hasPointsRecharged: false, }); @@ -92,7 +89,9 @@ describe('usePlatformProfileCenterController recharge fallback', () => { act(() => result.current.loadRechargeCenter()); await waitFor(() => { - expect(result.current.rechargeCenter?.walletBalance).toBe(37); + expect( + result.current.rechargeCenter?.mudPointBalance?.permanentPoints, + ).toBe(37); }); }); @@ -198,7 +197,9 @@ describe('usePlatformProfileCenterController recharge fallback', () => { expect(result.current.submittingRechargeProductId).toBeNull(); expect(result.current.nativeWechatPayment?.orderId).toBe('order-b'); expect(result.current.rechargePaymentResult).toBeNull(); - expect(result.current.rechargeCenter?.walletBalance).toBe(90); + expect( + result.current.rechargeCenter?.mudPointBalance?.permanentPoints, + ).toBe(90); expect(usePlatformWalletStore.getState()).toMatchObject({ ownerUserId: 'user-b', mudPointBalance: mudPointBalance(90), diff --git a/src/components/platform-entry/usePlatformProfileCenterController.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.test.tsx index 4c5506bc4..40b0e4d75 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.test.tsx +++ b/src/components/platform-entry/usePlatformProfileCenterController.test.tsx @@ -19,19 +19,17 @@ vi.mock('../../services/platform-entry/platformProfileClient', async () => { }); const rechargeCenter: ProfileRechargeCenterResponse = { - walletBalance: 100, mudPointBalance: { - totalPoints: 100, permanentPoints: 100, - limitedPoints: 0, - limitedExpiresAt: null, + monthlyPoints: 0, + monthlyResetsAt: null, dailyFreePoints: 0, dailyFreeResetPoints: 0, dailyFreeResetsAt: null, }, membership: { status: 'normal', - tier: 'normal', + plan: 'normal', startedAt: null, expiresAt: null, updatedAt: null, @@ -39,11 +37,12 @@ const rechargeCenter: ProfileRechargeCenterResponse = { cycleResetsAt: null, cycleGrantedPoints: 0, cycleRemainingPoints: 0, - cyclePeriodDays: 30, + cycleIndex: 1, + cycleCount: 1, + cycleKind: 'monthly', }, pointProducts: [], - membershipProducts: [], - benefits: [], + membershipPlans: [], latestOrder: null, hasPointsRecharged: false, }; diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 208eaccd7..eb27c29bb 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -1864,8 +1864,6 @@ export function usePlatformProfileCenterController({ } return { ...rechargeCenter, - walletBalance: - mudPointBalance?.totalPoints ?? rechargeCenter.walletBalance, mudPointBalance: mudPointBalance ?? rechargeCenter.mudPointBalance, }; }, [mudPointBalance, rechargeCenter, walletOwnerMatchesCurrentUser]); diff --git a/src/stores/usePlatformWalletStore.test.tsx b/src/stores/usePlatformWalletStore.test.tsx index 2b133a265..d7a95d234 100644 --- a/src/stores/usePlatformWalletStore.test.tsx +++ b/src/stores/usePlatformWalletStore.test.tsx @@ -22,12 +22,10 @@ beforeEach(() => { vi.clearAllMocks(); usePlatformWalletStore.getState().resetWalletBalance(); profileClientMocks.getPlatformProfileRechargeCenter.mockResolvedValue({ - walletBalance: 42, mudPointBalance: { - totalPoints: 42, permanentPoints: 42, - limitedPoints: 0, - limitedExpiresAt: null, + monthlyPoints: 0, + monthlyResetsAt: null, dailyFreePoints: 0, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-08-08T00:00:00+08:00', From de082ad81a62878773acb86f8ed4fcc2656b2a26 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 19:03:40 +0800 Subject: [PATCH 022/232] =?UTF-8?q?feat(=E5=90=8E=E5=8F=B0):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E4=BC=9A=E5=91=98=E6=A1=A3=E4=BD=8D=E9=A1=B5=E5=B9=B6?= =?UTF-8?q?=E6=94=B6=E6=95=9B=E5=85=85=E5=80=BC=E5=95=86=E5=93=81=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 充值商品页改为纯泥点商品:去掉会员档位与 membership_* 字段,固定 kind=points,首充赠送默认 0 - 新增会员档位页:按档位枚举改月价 / 年价 / 每期泥点 / 模型权限 / 并发上限 / 可购买 / 排序,rank 只读 - adminApiTypes 用档位枚举替换旧 tier 类型并补会员档位请求响应类型,客户端补 listProfileMembershipPlans / upsertProfileMembershipPlan - 注册 #membership-plans 路由与侧边栏图标 Co-authored-by: Junie --- apps/admin-web/src/api/adminApiClient.ts | 24 + apps/admin-web/src/api/adminApiTypes.ts | 56 ++- apps/admin-web/src/app/AdminApp.tsx | 12 + apps/admin-web/src/app/AdminShell.tsx | 1 + apps/admin-web/src/app/adminRoutes.ts | 2 + .../src/pages/AdminMembershipPlanPage.tsx | 461 ++++++++++++++++++ .../src/pages/AdminRechargeProductPage.tsx | 275 ++--------- 7 files changed, 579 insertions(+), 252 deletions(-) create mode 100644 apps/admin-web/src/pages/AdminMembershipPlanPage.tsx diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index ec574237d..71ea8d332 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -70,6 +70,7 @@ import type { AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertFeatureGateConfigRequest, AdminUpsertProfileInviteCodeRequest, + AdminUpsertProfileMembershipPlanRequest, AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, @@ -86,6 +87,8 @@ import type { EditorGenerationPricingConfigPayload, ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse, + ProfileMembershipPlanAdminListResponse, + ProfileMembershipPlanAdminResponse, ProfileRechargeProductConfigAdminListResponse, ProfileRechargeProductConfigAdminResponse, ProfileRedeemCodeAdminListResponse, @@ -791,6 +794,27 @@ export function upsertProfileRechargeProduct( ); } +export function listProfileMembershipPlans(token: string) { + return request( + '/admin/api/profile/membership-plans', + { token }, + ); +} + +export function upsertProfileMembershipPlan( + token: string, + payload: AdminUpsertProfileMembershipPlanRequest, +) { + return request( + '/admin/api/profile/membership-plans', + { + method: 'POST', + token, + body: payload, + }, + ); +} + export function listAdminRechargeOrders( token: string, query: AdminRechargeOrderListQuery = {}, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index c3a9e7256..598d3e571 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -381,15 +381,17 @@ export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private'; export type ProfileTaskCycle = 'daily'; export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user'; export type ProfileRechargeProductKind = 'points' | 'membership'; -export type ProfileMembershipTier = +/** 会员档位:`normal` 是非会员占位档位,不可购买。 */ +export type ProfileMembershipPlan = | 'normal' - | 'month' - | 'season' - | 'year' | 'starter' - | 'basic' + | 'plus' | 'pro' - | 'ultimate'; + | 'max'; +/** 会员周期:有效期内不支持月年互换。 */ +export type ProfileMembershipCycleKind = 'monthly' | 'yearly'; +/** 模型权限:`basic` 仅基础模型,`full` 基础及高性能模型。 */ +export type ProfileMembershipModelAccess = 'basic' | 'full'; export interface AdminTrackingEventListQuery { eventKey?: string; @@ -682,11 +684,6 @@ export interface AdminUpsertProfileRechargeProductRequest { durationDays: number; badgeLabel?: string | null; description?: string | null; - tier: ProfileMembershipTier; - membershipPeriodPoints: number; - membershipPeriodDays: number; - membershipQueueLimit: number; - membershipDiscountBps: number; enabled: boolean; sortOrder: number; } @@ -775,11 +772,6 @@ export interface ProfileRechargeProductConfigAdminResponse { durationDays: number; badgeLabel: string; description: string; - tier: ProfileMembershipTier; - membershipPeriodPoints: number; - membershipPeriodDays: number; - membershipQueueLimit: number; - membershipDiscountBps: number; enabled: boolean; sortOrder: number; createdBy: string; @@ -792,6 +784,38 @@ export interface ProfileRechargeProductConfigAdminListResponse { entries: ProfileRechargeProductConfigAdminResponse[]; } +/** 会员档位目录行;后端 `profile_membership_plan` 表是价格与权益的唯一真相源。 */ +export interface ProfileMembershipPlanAdminResponse { + plan: ProfileMembershipPlan; + title: string; + rank: number; + monthPriceCents: number; + yearPriceCents: number; + periodPoints: number; + modelAccess: ProfileMembershipModelAccess; + /** 并发上限;`128` 表示不设上限。 */ + concurrentJobLimit: number; + sortOrder: number; + enabled: boolean; +} + +export interface ProfileMembershipPlanAdminListResponse { + entries: ProfileMembershipPlanAdminResponse[]; +} + +/** 后台按档位改价 / 改权益;`rank` 不在请求里,升级比较基准由代码内目录决定。 */ +export interface AdminUpsertProfileMembershipPlanRequest { + plan: ProfileMembershipPlan; + title: string; + monthPriceCents: number; + yearPriceCents: number; + periodPoints: number; + modelAccess: ProfileMembershipModelAccess; + concurrentJobLimit: number; + enabled: boolean; + sortOrder: number; +} + export interface ProfileWalletConfigAdminResponse { configId: string; initialMudPoints: number; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 374bc8e45..e89cc7b7c 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -9,6 +9,7 @@ import { } from '../api/adminApiClient'; import type { AdminSessionPayload, + ProfileMembershipPlanAdminResponse, ProfileRechargeProductConfigAdminResponse, ProfileTaskConfigAdminResponse, ProfileWalletConfigAdminResponse, @@ -35,6 +36,7 @@ import { AdminGameReviewsPage } from '../pages/AdminGameReviewsPage'; import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage'; import { AdminInviteCodePage } from '../pages/AdminInviteCodePage'; import { AdminLoginPage } from '../pages/AdminLoginPage'; +import { AdminMembershipPlanPage } from '../pages/AdminMembershipPlanPage'; import { AdminOverviewPage } from '../pages/AdminOverviewPage'; import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage'; import { AdminProjectSnapshotsPage } from '../pages/AdminProjectSnapshotsPage'; @@ -68,6 +70,8 @@ export function AdminApp() { useState(null); const [rechargeProductResult, setRechargeProductResult] = useState(null); + const [membershipPlanResult, setMembershipPlanResult] = + useState(null); const accessibleRoutes = useMemo( () => (admin ? getAccessibleAdminRoutes(admin) : []), [admin], @@ -291,6 +295,14 @@ export function AdminApp() { onResultChange={setRechargeProductResult} /> ) : null} + {activeRouteId === 'membership-plans' ? ( + + ) : null} {activeRouteId === 'recharge-orders' ? ( void; + onResultChange: (result: ProfileMembershipPlanAdminResponse) => void; +} + +/** 并发上限哨兵:与后端 `MEMBERSHIP_UNLIMITED_CONCURRENCY` 一致,128 表示不设上限。 */ +const UNLIMITED_CONCURRENT_JOB_LIMIT = 128; + +const modelAccessOptions: Array<{ + value: ProfileMembershipModelAccess; + label: string; +}> = [ + { value: 'basic', label: '仅基础模型' }, + { value: 'full', label: '基础及高性能模型' }, +]; + +const planLabels: Record = { + normal: '非会员', + starter: 'Starter', + plus: 'Plus', + pro: 'Pro', + max: 'Max', +}; + +/** + * 会员档位目录。 + * + * 一行一档,档位即主键(也是身份)。价格与权益以本表为唯一真相源:充值中心展示、 + * 下单金额重算、补差升级报价都读它。`rank` 由代码内的档位目录决定,后台不能改, + * 因此这里只读展示。模型权限与并发上限本期只落字段、只用于展示,服务端不做拦截。 + */ +export function AdminMembershipPlanPage({ + token, + result, + onUnauthorized, + onResultChange, +}: AdminMembershipPlanPageProps) { + const [entries, setEntries] = useState( + [], + ); + const [plan, setPlan] = useState('starter'); + const [title, setTitle] = useState(''); + const [monthPriceCents, setMonthPriceCents] = useState('0'); + const [yearPriceCents, setYearPriceCents] = useState('0'); + const [periodPoints, setPeriodPoints] = useState('0'); + const [modelAccess, setModelAccess] = + useState('basic'); + const [concurrentJobLimit, setConcurrentJobLimit] = useState('1'); + const [enabled, setEnabled] = useState(false); + const [sortOrder, setSortOrder] = useState('0'); + const [rankLabel, setRankLabel] = useState('0'); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [listErrorMessage, setListErrorMessage] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const { confirmWrite, confirmDialog } = useAdminWriteConfirm(); + + useEffect(() => { + void refreshPlans(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + async function refreshPlans() { + setIsLoading(true); + setListErrorMessage(''); + try { + const response = await listProfileMembershipPlans(token); + setEntries(response.entries); + const firstEntry = + response.entries.find((entry) => entry.plan === plan) ?? + response.entries[0]; + if (firstEntry) { + fillForm(firstEntry); + } + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setListErrorMessage); + } finally { + setIsLoading(false); + } + } + + async function handleSave(event: FormEvent) { + event.preventDefault(); + if (isSaving) { + return; + } + + setErrorMessage(''); + const normalizedMonthPriceCents = parsePositiveInteger(monthPriceCents); + const normalizedYearPriceCents = parsePositiveInteger(yearPriceCents); + const normalizedConcurrentJobLimit = + parsePositiveInteger(concurrentJobLimit); + if (plan !== 'normal') { + if (!normalizedMonthPriceCents || !normalizedYearPriceCents) { + setErrorMessage('月价与年价必须是大于 0 的整数分'); + return; + } + if (!normalizedConcurrentJobLimit) { + setErrorMessage( + `并发上限必须是大于 0 的整数;${UNLIMITED_CONCURRENT_JOB_LIMIT} 表示不设上限`, + ); + return; + } + } + + const confirmed = await confirmWrite({ + action: '保存会员档位', + target: `${planLabels[plan]}(${plan})`, + }); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + const response = await upsertProfileMembershipPlan(token, { + plan, + title: title.trim(), + monthPriceCents: normalizedMonthPriceCents, + yearPriceCents: normalizedYearPriceCents, + periodPoints: parsePositiveInteger(periodPoints), + modelAccess, + concurrentJobLimit: normalizedConcurrentJobLimit, + enabled, + sortOrder: parseInteger(sortOrder), + }); + onResultChange(response); + upsertEntry(response); + fillForm(response); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsSaving(false); + } + } + + function upsertEntry(next: ProfileMembershipPlanAdminResponse) { + setEntries((current) => { + const rest = current.filter((entry) => entry.plan !== next.plan); + return sortPlanEntries([...rest, next]); + }); + } + + function fillForm(entry: ProfileMembershipPlanAdminResponse) { + setPlan(entry.plan); + setTitle(entry.title); + setMonthPriceCents(String(entry.monthPriceCents)); + setYearPriceCents(String(entry.yearPriceCents)); + setPeriodPoints(String(entry.periodPoints)); + setModelAccess(entry.modelAccess); + setConcurrentJobLimit(String(entry.concurrentJobLimit)); + setEnabled(entry.enabled); + setSortOrder(String(entry.sortOrder)); + setRankLabel(String(entry.rank)); + } + + const isPurchasable = plan !== 'normal'; + + return ( + + + + ); +} + +function sortPlanEntries(entries: ProfileMembershipPlanAdminResponse[]) { + return [...entries].sort((left, right) => { + if (left.sortOrder !== right.sortOrder) { + return left.sortOrder - right.sortOrder; + } + return left.rank - right.rank; + }); +} + +function formatPlanLimits(entry: ProfileMembershipPlanAdminResponse) { + const modelLabel = entry.modelAccess === 'full' ? '基础+高性能' : '仅基础'; + const concurrency = + entry.concurrentJobLimit >= UNLIMITED_CONCURRENT_JOB_LIMIT + ? '不设上限' + : `${entry.concurrentJobLimit} 个`; + return `${modelLabel} · 并发 ${concurrency}`; +} + +function formatPrice(priceCents: number) { + return `¥${(priceCents / 100).toFixed(2)}`; +} + +function parsePositiveInteger(value: string) { + const parsed = parseInteger(value); + return parsed > 0 ? parsed : 0; +} + +function parseInteger(value: string) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + return 0; + } + return parsed; +} diff --git a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx index 6fbaa879a..cf95a7030 100644 --- a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx @@ -8,6 +8,7 @@ import { AdminPage, AdminPageHeading, AdminPanel, + formatAdminDateTime, } from '@genarrative/shared/components'; import { RefreshCcw, Save } from 'lucide-react'; import { FormEvent, useEffect, useState } from 'react'; @@ -16,11 +17,7 @@ import { listProfileRechargeProducts, upsertProfileRechargeProduct, } from '../api/adminApiClient'; -import type { - ProfileMembershipTier, - ProfileRechargeProductConfigAdminResponse, - ProfileRechargeProductKind, -} from '../api/adminApiTypes'; +import type { ProfileRechargeProductConfigAdminResponse } from '../api/adminApiTypes'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; import { handlePageError } from './pageUtils'; @@ -31,25 +28,12 @@ interface AdminRechargeProductPageProps { onResultChange: (result: ProfileRechargeProductConfigAdminResponse) => void; } -const productKinds: Array<{ - value: ProfileRechargeProductKind; - label: string; -}> = [ - { value: 'points', label: '泥点' }, - { value: 'membership', label: '会员' }, -]; - -const membershipTiers: Array<{ value: ProfileMembershipTier; label: string }> = - [ - { value: 'starter', label: 'Starter' }, - { value: 'basic', label: 'Basic' }, - { value: 'pro', label: 'Pro' }, - { value: 'ultimate', label: 'Ultimate' }, - { value: 'month', label: '月卡' }, - { value: 'season', label: '季卡' }, - { value: 'year', label: '年卡' }, - ]; - +/** + * 泥点充值商品。 + * + * 会员档位已迁到独立的「会员档位」页(`profile_membership_plan`),本页只维护充点商品。 + * 新方案固定「¥1 = 10 泥点、无任何首充赠送」,首充赠送字段保留能力但默认 0。 + */ export function AdminRechargeProductPage({ token, result, @@ -62,17 +46,10 @@ export function AdminRechargeProductPage({ const [productId, setProductId] = useState('points_60'); const [title, setTitle] = useState('60泥点'); const [priceCents, setPriceCents] = useState('600'); - const [kind, setKind] = useState('points'); const [pointsAmount, setPointsAmount] = useState('60'); const [bonusPoints, setBonusPoints] = useState('0'); - const [durationDays, setDurationDays] = useState('0'); const [badgeLabel, setBadgeLabel] = useState(''); const [description, setDescription] = useState('60泥点'); - const [tier, setTier] = useState('normal'); - const [membershipPeriodPoints, setMembershipPeriodPoints] = useState('0'); - const [membershipPeriodDays, setMembershipPeriodDays] = useState('0'); - const [membershipQueueLimit, setMembershipQueueLimit] = useState('0'); - const [membershipDiscountBps, setMembershipDiscountBps] = useState('0'); const [enabled, setEnabled] = useState(true); const [sortOrder, setSortOrder] = useState('0'); const [isLoading, setIsLoading] = useState(false); @@ -125,32 +102,12 @@ export function AdminRechargeProductPage({ productId: productId.trim(), title: title.trim(), priceCents: parsePositiveInteger(priceCents), - kind, - pointsAmount: - kind === 'points' ? parsePositiveInteger(pointsAmount) : 0, - bonusPoints: - kind === 'points' ? parseNonNegativeInteger(bonusPoints) : 0, - durationDays: - kind === 'membership' ? parsePositiveInteger(durationDays) : 0, - badgeLabel: kind === 'points' ? badgeLabel.trim() : '', + kind: 'points', + pointsAmount: parsePositiveInteger(pointsAmount), + bonusPoints: parseNonNegativeInteger(bonusPoints), + durationDays: 0, + badgeLabel: badgeLabel.trim(), description: description.trim(), - tier: kind === 'membership' ? tier : 'normal', - membershipPeriodPoints: - kind === 'membership' - ? parsePositiveInteger(membershipPeriodPoints) - : 0, - membershipPeriodDays: - kind === 'membership' - ? parsePositiveInteger(membershipPeriodDays) - : 0, - membershipQueueLimit: - kind === 'membership' - ? parseNonNegativeInteger(membershipQueueLimit) - : 0, - membershipDiscountBps: - kind === 'membership' - ? parseNonNegativeInteger(membershipDiscountBps) - : 0, enabled, sortOrder: parseInteger(sortOrder), }); @@ -177,17 +134,10 @@ export function AdminRechargeProductPage({ setProductId(entry.productId); setTitle(entry.title); setPriceCents(String(entry.priceCents)); - setKind(entry.kind); setPointsAmount(String(entry.pointsAmount)); setBonusPoints(String(entry.bonusPoints)); - setDurationDays(String(entry.durationDays)); setBadgeLabel(entry.badgeLabel); setDescription(entry.description); - setTier(entry.tier); - setMembershipPeriodPoints(String(entry.membershipPeriodPoints)); - setMembershipPeriodDays(String(entry.membershipPeriodDays)); - setMembershipQueueLimit(String(entry.membershipQueueLimit)); - setMembershipDiscountBps(String(entry.membershipDiscountBps)); setEnabled(entry.enabled); setSortOrder(String(entry.sortOrder)); } @@ -196,7 +146,7 @@ export function AdminRechargeProductPage({ -
- {productKinds.map((item) => ( - - ))} -
- - {kind === 'points' ? ( - - - setPointsAmount(event.target.value)} - /> - - - setBonusPoints(event.target.value)} - /> - - - ) : ( -
- - - - - - setDurationDays(event.target.value)} - /> - - - - - - setMembershipPeriodPoints(event.target.value) - } - /> - - - - setMembershipPeriodDays(event.target.value) - } - /> - - - - - - setMembershipQueueLimit(event.target.value) - } - /> - - - - setMembershipDiscountBps(event.target.value) - } - /> - - -
- )} + + + setPointsAmount(event.target.value)} + /> + + + setBonusPoints(event.target.value)} + /> + + setBadgeLabel(event.target.value)} /> @@ -430,12 +268,6 @@ export function AdminRechargeProductPage({ sortable: true, sortValue: (entry) => entry.title || entry.productId, }, - { - key: 'kind', - label: '类型', - sortable: true, - sortValue: (entry) => formatProductKind(entry.kind), - }, { key: 'price', label: '价格', @@ -468,7 +300,6 @@ export function AdminRechargeProductPage({ {entry.productId} - {formatProductKind(entry.kind)} {formatPrice(entry.priceCents)} {formatProductContent(entry)} {entry.enabled ? '启用' : '停用'} @@ -489,6 +320,10 @@ export function AdminRechargeProductPage({ label: <>状态, value: <>{result.enabled ? '启用' : '停用'}, }, + { + label: <>时间, + value: <>{formatAdminDateTime(result.updatedAt)}, + }, ]} /> @@ -510,42 +345,10 @@ function sortProducts(entries: ProfileRechargeProductConfigAdminResponse[]) { }); } -function formatProductKind(kind: ProfileRechargeProductKind) { - return kind === 'points' ? '泥点' : '会员'; -} - -function formatTier(tier: ProfileMembershipTier) { - if (tier === 'starter') { - return 'Starter'; - } - if (tier === 'basic') { - return 'Basic'; - } - if (tier === 'pro') { - return 'Pro'; - } - if (tier === 'ultimate') { - return 'Ultimate'; - } - if (tier === 'month') { - return '月卡'; - } - if (tier === 'season') { - return '季卡'; - } - if (tier === 'year') { - return '年卡'; - } - return '普通'; -} - function formatProductContent( entry: ProfileRechargeProductConfigAdminResponse, ) { - if (entry.kind === 'points') { - return `${entry.pointsAmount}+${entry.bonusPoints}`; - } - return `${formatTier(entry.tier)} ${entry.durationDays}天 · 每${entry.membershipPeriodDays}天${entry.membershipPeriodPoints}泥点`; + return `${entry.pointsAmount} + ${entry.bonusPoints} 泥点`; } function formatPrice(priceCents: number) { From a7cde25b285f4aa741943c2d2493b9736ee20ace 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 19:03:55 +0800 Subject: [PATCH 023/232] =?UTF-8?q?docs(=E8=AE=A1=E8=B4=B9):=20=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E6=96=87=E6=A1=A3=E4=B8=8E=E5=86=B3=E7=AD=96=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=9B=B4=E6=96=B0=E4=B8=BA=E5=B7=B2=E8=90=BD=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 【技术设计】泥点三池与会员计费后端设计:余额投影改为扁平三池标量、报价请求不收周期、会员订单 product_id 编码档位与周期、充值中心改 membershipPlans,新增后台接口与 ts-rs 契约生成小节 - §10.2 实施状态由「待落地清单」改写为「已落地 + 明确不做」,注明永久余数仍是推导实现 - docs/README 索引标注两份文档已落地;decision-log 追加 2026-10-02 状态与实现期补充决断 Co-authored-by: Junie --- docs/README.md | 4 +- .../shared-memory/decision-log.md | 8 +- ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 133 +++++++++++++----- 3 files changed, 103 insertions(+), 42 deletions(-) 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 变更同批提交。 From 5bb98fd8fce7dae962629121f59e2f9b224530ec 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 19:05:57 +0800 Subject: [PATCH 024/232] =?UTF-8?q?chore(=E5=A5=91=E7=BA=A6):=20=E7=94=9F?= =?UTF-8?q?=E6=88=90=E4=BA=A7=E7=89=A9=E4=B8=8D=E8=BF=9B=20prettier?= =?UTF-8?q?=EF=BC=8C=E6=81=A2=E5=A4=8D=E9=80=90=E5=AD=97=E8=8A=82=E4=B8=80?= =?UTF-8?q?=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .prettierignore 排除 packages/shared/src/contracts/generated/:prettier 重排会让 check:generated-bindings 的「重新生成逐字节比对」永远失败 - 按 ts-rs 实际输出重新生成 ProfileMudPointBalanceResponse.ts(含字段文档注释) Co-authored-by: Junie --- .prettierignore | 2 ++ .../ProfileMudPointBalanceResponse.ts | 30 ++++++++----------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/.prettierignore b/.prettierignore index 9ec6037f3..0239b5197 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,6 +2,8 @@ dist node_modules .git .codex-logs +# ts-rs 生成产物:必须与 Rust 声明逐字节一致,`check:generated-bindings` 会重新生成并比对。 +packages/shared/src/contracts/generated/ public/Icons apps/ai-game-creator-shell/src/features/ui-editor/types/ apps/ai-game-creator-shell/src-tauri/resources/agc-skills/ diff --git a/packages/shared/src/contracts/generated/ProfileMudPointBalanceResponse.ts b/packages/shared/src/contracts/generated/ProfileMudPointBalanceResponse.ts index 65cd3039a..5a0ad0526 100644 --- a/packages/shared/src/contracts/generated/ProfileMudPointBalanceResponse.ts +++ b/packages/shared/src/contracts/generated/ProfileMudPointBalanceResponse.ts @@ -1,19 +1,15 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ProfileMudPointBalanceResponse = { - /** - * 每日免费泥点:北京业务日有效,跨日清零并重置。 - */ - dailyFreePoints: number; - dailyFreeResetPoints: number; - dailyFreeResetsAt: string; - /** - * 月度泥点:会员每期额度,到刷新时刻清零再发新额度。 - */ - monthlyPoints: number; - monthlyResetsAt: string | null; - /** - * 永久泥点:不过期,创作照常扣点。 - */ - permanentPoints: number; -}; +export type ProfileMudPointBalanceResponse = { +/** + * 每日免费泥点:北京业务日有效,跨日清零并重置。 + */ +dailyFreePoints: number, dailyFreeResetPoints: number, dailyFreeResetsAt: string, +/** + * 月度泥点:会员每期额度,到刷新时刻清零再发新额度。 + */ +monthlyPoints: number, monthlyResetsAt: string | null, +/** + * 永久泥点:不过期,创作照常扣点。 + */ +permanentPoints: number, }; From 603ecb08871d1f8e5c394a3455e4f63abca0b4b8 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 19:11:59 +0800 Subject: [PATCH 025/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E8=AE=A1=E8=B4=B9):?= =?UTF-8?q?=20=E6=98=9F=E6=9C=9F=E6=8D=A2=E7=AE=97=E9=81=BF=E5=85=8D=20i64?= =?UTF-8?q?=20=E6=BA=A2=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit weekday_from_date_key 先按 7 取模再加偏移,消除 date_key 接近 i64::MAX 时的中间加法溢出 Co-authored-by: Junie --- server-rs/crates/module-runtime/src/civil_calendar.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server-rs/crates/module-runtime/src/civil_calendar.rs b/server-rs/crates/module-runtime/src/civil_calendar.rs index 60c0a4ec8..bc6cec172 100644 --- a/server-rs/crates/module-runtime/src/civil_calendar.rs +++ b/server-rs/crates/module-runtime/src/civil_calendar.rs @@ -54,7 +54,7 @@ pub fn civil_from_days(date_key: i64) -> (i32, u8, u8) { /// ISO 星期:周一 = 1,周日 = 7。 pub fn weekday_from_date_key(date_key: i64) -> u8 { // 中文注释:1970-01-01 是周四;这里返回 ISO weekday,周一=1,周日=7。 - (date_key + 3).rem_euclid(7) as u8 + 1 + (date_key.rem_euclid(7) + 3).rem_euclid(7) as u8 + 1 } #[cfg(test)] From 8b8213f0c8b6a9e90d11c677da463f7b63b6b367 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 19:12:34 +0800 Subject: [PATCH 026/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E8=AE=A1=E8=B4=B9):?= =?UTF-8?q?=20=E5=85=AC=E5=8E=86=E5=8F=8D=E7=AE=97=E9=81=BF=E5=85=8D=20i64?= =?UTF-8?q?=20=E6=BA=A2=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit civil_from_days 用 saturating_add 计算日序号偏移,消除 date_key 接近 i64::MAX 时的加法溢出 Co-authored-by: Junie --- server-rs/crates/module-runtime/src/civil_calendar.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server-rs/crates/module-runtime/src/civil_calendar.rs b/server-rs/crates/module-runtime/src/civil_calendar.rs index bc6cec172..036bf1a2d 100644 --- a/server-rs/crates/module-runtime/src/civil_calendar.rs +++ b/server-rs/crates/module-runtime/src/civil_calendar.rs @@ -36,7 +36,7 @@ pub fn days_from_civil(year: i32, month: u8, day: u8) -> i64 { /// 1970-01-01 起的日序号 → 公历年月日。 pub fn civil_from_days(date_key: i64) -> (i32, u8, u8) { // 中文注释:days_from_civil 的反向算法,避免依赖运行环境时区。 - let z = date_key + 719_468; + let z = date_key.saturating_add(719_468); let era = z.div_euclid(146_097); let day_of_era = z - era * 146_097; let year_of_era = (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 From f81006465728543e2ba5f166790c900d1ce5dd21 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 19:13:10 +0800 Subject: [PATCH 027/232] =?UTF-8?q?=E6=96=87=E6=A1=A3(=E8=AE=A1=E8=B4=B9):?= =?UTF-8?q?=20=E6=BE=84=E6=B8=85=E6=A1=A3=E4=BD=8D=E6=8E=92=E5=BA=8F?= =?UTF-8?q?=E4=BB=A5=E7=9B=AE=E5=BD=95=20rank=20=E4=B8=BA=E5=87=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RuntimeProfileMembershipPlan 文档改为说明排序由目录 rank 决定、枚举不实现 Ord,避免误以为变体顺序是排序契约 Co-authored-by: Junie --- server-rs/crates/module-runtime/src/domain.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index dd5f8d519..d1919d53c 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1233,7 +1233,10 @@ impl RuntimeProfileMembershipStatus { /// 会员档位身份。取代旧的 `RuntimeProfileMembershipTier`,作为唯一权威档位标识。 /// -/// 变体顺序即排序基准(`Normal < Starter < Plus < Pro < Max`)。对外 wire 只用 +/// 档位高低由目录表的 `rank` 决定(见 +/// [`runtime_profile_membership_plan_rank`](crate::runtime_profile_membership_plan_rank)); +/// 本枚举不实现 `PartialOrd` / `Ord`,变体声明顺序不构成排序契约(当前恰与 `rank` 一致), +/// 升级比较一律以目录 `rank` 为唯一来源。对外 wire 只用 /// [`RuntimeProfileMembershipPlan::as_str`] 产出的枚举 token。 #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] From 5ab6f161d0bb099e397d8907e647e0820647440c 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 19:13:31 +0800 Subject: [PATCH 028/232] =?UTF-8?q?=E6=96=87=E6=A1=A3(=E8=AE=A1=E8=B4=B9):?= =?UTF-8?q?=20=E4=BF=AE=E6=AD=A3=E5=8C=97=E4=BA=AC=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E6=8D=A2=E7=AE=97=E7=9A=84=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E5=80=BC=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit beijing_local_micros 文档由「本地日期不存在返回原值」改为「返回 0」,与实际实现一致 Co-authored-by: Junie --- server-rs/crates/module-runtime/src/membership/cycle.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server-rs/crates/module-runtime/src/membership/cycle.rs b/server-rs/crates/module-runtime/src/membership/cycle.rs index 43e76b5e4..4ac059a73 100644 --- a/server-rs/crates/module-runtime/src/membership/cycle.rs +++ b/server-rs/crates/module-runtime/src/membership/cycle.rs @@ -20,7 +20,7 @@ pub fn beijing_local_date_parts(micros: i64) -> (i32, u8, u8, i64) { (year, month, day, time_of_day_micros) } -/// 北京时间本地年月日 + 当日已过微秒 → UTC 微秒时间戳;本地日期不存在则返回原值。 +/// 北京时间本地年月日 + 当日已过微秒 → UTC 微秒时间戳;本地日期不存在时返回 0。 pub fn beijing_local_micros(year: i32, month: u8, day: u8, time_of_day_micros: i64) -> i64 { if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) { return 0; From 58a79a2e4ef8131e025f7a502f3ab9806cc57695 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 19:22:36 +0800 Subject: [PATCH 029/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E8=AE=A1=E8=B4=B9):?= =?UTF-8?q?=20=E5=8C=97=E4=BA=AC=E6=9C=AC=E5=9C=B0=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=8D=A2=E7=AE=97=E6=A0=A1=E9=AA=8C=E5=BD=93=E6=97=A5=E6=97=B6?= =?UTF-8?q?=E5=88=BB=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit beijing_local_micros 增加 time_of_day_micros 越界即返回 0 的校验,避免越界时刻被解释成另一天 Co-authored-by: Junie --- server-rs/crates/module-runtime/src/membership/cycle.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/server-rs/crates/module-runtime/src/membership/cycle.rs b/server-rs/crates/module-runtime/src/membership/cycle.rs index 4ac059a73..700a6cd95 100644 --- a/server-rs/crates/module-runtime/src/membership/cycle.rs +++ b/server-rs/crates/module-runtime/src/membership/cycle.rs @@ -20,9 +20,13 @@ pub fn beijing_local_date_parts(micros: i64) -> (i32, u8, u8, i64) { (year, month, day, time_of_day_micros) } -/// 北京时间本地年月日 + 当日已过微秒 → UTC 微秒时间戳;本地日期不存在时返回 0。 +/// 北京时间本地年月日 + 当日已过微秒 → UTC 微秒时间戳;本地日期或当日时刻越界时返回 0。 pub fn beijing_local_micros(year: i32, month: u8, day: u8, time_of_day_micros: i64) -> i64 { - if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) { + if !(1..=12).contains(&month) + || day == 0 + || day > days_in_month(year, month) + || !(0..PROFILE_RUNTIME_DAY_MICROS).contains(&time_of_day_micros) + { return 0; } days_from_civil(year, month, day) From 8e1471d7af9544afefb9a5e031dba2000d694cbc 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 19:23:36 +0800 Subject: [PATCH 030/232] =?UTF-8?q?=E6=B5=8B=E8=AF=95(=E8=AE=A1=E8=B4=B9):?= =?UTF-8?q?=20=E5=9B=BA=E5=8C=96=E4=BC=9A=E5=91=98=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E4=BB=B7=E6=A0=BC=E4=B8=8E=E6=B3=A5=E7=82=B9=E9=9A=8F=20rank?= =?UTF-8?q?=20=E9=80=92=E5=A2=9E=E7=9A=84=E4=B8=8D=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 catalog 文档写明升级补差依赖的单调性不变量,并新增回归测试断言 rank 越高月价、年价、每期泥点严格递增 Co-authored-by: Junie --- .../module-runtime/src/membership/catalog.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/server-rs/crates/module-runtime/src/membership/catalog.rs b/server-rs/crates/module-runtime/src/membership/catalog.rs index 1fee74750..e7d2860e3 100644 --- a/server-rs/crates/module-runtime/src/membership/catalog.rs +++ b/server-rs/crates/module-runtime/src/membership/catalog.rs @@ -8,6 +8,10 @@ //! 价格是**后端唯一真相**,前端展示价只用于渲染,下单时后端按同一份目录重算。 //! //! 本期模型权限只落字段、只用于展示,不做服务端拦截;并发上限只做判定,同样不拦截。 +//! +//! 目录不变量:按 `rank` 升序,月价、年价、每期泥点必须**严格递增**(`Normal` 为 0)。 +//! 升级补差按 `rank` 判定、按价差 / 点差计价,若高 `rank` 的行反而更便宜或更少点, +//! `saturating_sub` 会把补差静默算成 0;后台改价时不得破坏该不变量。 use serde::{Deserialize, Serialize}; @@ -298,6 +302,35 @@ mod tests { assert!(is_unlimited_concurrency(max.concurrent_job_limit)); } + #[test] + fn enabled_plans_increase_in_price_and_points_with_rank() { + // 中文注释:升级补差依赖「rank 越高、价格与每期泥点越大」这一不变量; + // 后台改价破坏它时,升级会静默算成 0,因此把种子目录的不变量固化成回归断言。 + let mut rows = runtime_profile_membership_plan_catalog(); + rows.sort_by_key(|row| row.rank); + for pair in rows.windows(2) { + let (lower, higher) = (&pair[0], &pair[1]); + assert!( + higher.month_price_cents > lower.month_price_cents, + "{} 的月价必须高于 {}", + higher.title, + lower.title + ); + assert!( + higher.year_price_cents > lower.year_price_cents, + "{} 的年价必须高于 {}", + higher.title, + lower.title + ); + assert!( + higher.period_points > lower.period_points, + "{} 的每期泥点必须高于 {}", + higher.title, + lower.title + ); + } + } + #[test] fn year_price_is_stored_independently_of_the_month_price() { // 中文注释:年价是独立可配置字段,这里断言的是种子数值本身,而不是月价 ×10 的推导关系。 From f09701f6d456bd0f7ec9d70881a117d8d1c30a52 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 19:56:54 +0800 Subject: [PATCH 031/232] =?UTF-8?q?=E9=87=8D=E6=9E=84(=E8=AE=A1=E8=B4=B9):?= =?UTF-8?q?=20module-runtime=20=E5=8E=BB=E6=8E=89=E9=80=9A=E9=85=8D=20re-e?= =?UTF-8?q?xport=20=E6=94=B9=E4=B8=BA=E7=AD=96=E5=B1=95=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib.rs 移除 pub use civil_calendar::*,公历基元只保留 civil_calendar:: 命名空间 - lib.rs 移除 pub use membership::*,改为只重导出对外的会员契约符号 - 未改动 spacetime-module / spacetime-client / api-server,现有引用保持可用 Co-authored-by: Junie --- server-rs/crates/module-runtime/src/lib.rs | 26 ++++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index ce5c04494..614e9695a 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -1,22 +1,34 @@ pub mod agc_analytics; -mod agc_models; pub mod civil_calendar; -pub use civil_calendar::*; -mod llm_billing; -pub use llm_billing::*; -mod application; pub mod membership; -pub use agc_models::*; + +mod agc_models; +mod application; mod commands; mod domain; mod errors; mod events; +mod llm_billing; +pub use agc_models::*; pub use application::*; pub use commands::*; pub use domain::*; pub use errors::*; -pub use membership::*; +pub use llm_billing::*; + +// 中文注释:`membership` 与 `civil_calendar` 不再用 `pub use ...::*` 摊平到 crate 根: +// 通配会把公历基元和目录内部件都变成公开 API,模块继续增长后容易命名冲突; +// 这里只重导出真正对外的会员契约符号,其余留在 `membership::` / `civil_calendar::` 命名空间下。 +pub use membership::{ + MEMBERSHIP_UNLIMITED_CONCURRENCY, RuntimeProfileMembershipPlanRecord, + RuntimeProfileMembershipPlanSnapshot, RuntimeProfileMembershipUpgradeQuoteInput, + beijing_local_micros, build_runtime_profile_membership_plan_snapshot, is_unlimited_concurrency, + membership_cycle_window, membership_expires_at, parse_runtime_profile_membership_product_id, + quote_runtime_profile_membership_upgrade, resolve_runtime_profile_membership_plan_row, + runtime_profile_membership_plan_catalog, runtime_profile_membership_plan_rank, + runtime_profile_membership_product_id, +}; use shared_kernel::format_rfc3339 as format_shared_rfc3339; use time::OffsetDateTime; From 83dbd0cc776843b1b89187386df893f3ea9de44e 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 19:57:04 +0800 Subject: [PATCH 032/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E8=AE=A1=E8=B4=B9):?= =?UTF-8?q?=20=E5=8D=87=E7=BA=A7=E6=8A=A5=E4=BB=B7=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E8=B6=8A=E7=95=8C=E6=95=B0=E5=80=BC=E5=85=A5=E5=8F=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput 及 wire token - 报价前校验期号范围与账期窗口,避免 saturating_sub 静默夹取 - 新增 invalid_numeric_input_is_rejected 回归测试 Co-authored-by: Junie --- .../module-runtime/src/membership/upgrade.rs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index 65088e2f8..175e77a06 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -67,6 +67,8 @@ pub enum RuntimeProfileMembershipUpgradeRejection { NotUpgrade, /// 目标档位不可购买(非会员占位或已下架)。 TargetPlanNotPurchasable, + /// 报价入参的数值不变量被破坏(期号越界或账期窗口非法):拒绝,而不是静默夹取。 + InvalidQuoteInput, } impl RuntimeProfileMembershipUpgradeRejection { @@ -76,6 +78,7 @@ impl RuntimeProfileMembershipUpgradeRejection { Self::CycleKindChangeUnsupported => "cycle_kind_change_unsupported", Self::NotUpgrade => "not_upgrade", Self::TargetPlanNotPurchasable => "target_plan_not_purchasable", + Self::InvalidQuoteInput => "invalid_quote_input", } } } @@ -116,10 +119,28 @@ pub fn check_runtime_profile_membership_upgrade_allowed( Ok(()) } +/// 报价入参的数值自检。生产入口从会员行构造入参,这些不变量恒成立; +/// 在此显式校验是为了不让越界值被 `saturating_sub` 静默夹取后算出错误金额。 +/// +/// 刻意不做的两项(见 review 第 12 项):`cycle_count` 与周期类型的对齐(月付 1 / 年付 12)、 +/// `now` 的窗口上界——已过期报价按「剩余比例为 0」返回,属既有契约。 +fn validate_runtime_profile_membership_upgrade_quote_input( + input: &RuntimeProfileMembershipUpgradeQuoteInput, +) -> Result<(), RuntimeProfileMembershipUpgradeRejection> { + let cycle_index_out_of_range = input.cycle_index == 0 || input.cycle_index > input.cycle_count; + let window_out_of_order = input.cycle_resets_at_micros < input.cycle_started_at_micros; + let now_before_window = input.now_micros < input.cycle_started_at_micros; + if cycle_index_out_of_range || window_out_of_order || now_before_window { + return Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput); + } + Ok(()) +} + /// 计算补差升级报价;报价与下单重算必须共用本函数。 pub fn quote_runtime_profile_membership_upgrade( input: RuntimeProfileMembershipUpgradeQuoteInput, ) -> Result { + validate_runtime_profile_membership_upgrade_quote_input(&input)?; check_runtime_profile_membership_upgrade_allowed(&input.current, &input.target)?; let current = input.current; @@ -344,6 +365,38 @@ mod tests { ); } + #[test] + fn invalid_numeric_input_is_rejected() { + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + cycle_index: 0, + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + cycle_index: base_input().cycle_count + 1, + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + cycle_resets_at_micros: beijing(2026, 6, 1, 9), + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + now_micros: beijing(2026, 6, 1, 9), + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + } + #[test] fn cycle_kind_change_is_rejected() { assert_eq!( From 3eaae417baeeed7060903cdedd83c52c78a3c4f5 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 19:58:39 +0800 Subject: [PATCH 033/232] =?UTF-8?q?=E6=96=87=E6=A1=A3(=E8=AE=A1=E8=B4=B9):?= =?UTF-8?q?=20=E8=A1=A5=E5=85=85=E5=8D=87=E7=BA=A7=E6=8A=A5=E4=BB=B7?= =?UTF-8?q?=E6=95=B0=E5=80=BC=E5=85=A5=E5=8F=82=E8=87=AA=E6=A3=80=E8=AF=B4?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在领域数据结构 §3 记录 quote_runtime_profile_membership_upgrade 的防御式自检与 invalid_quote_input 拒绝 Co-authored-by: Junie --- .../【技术设计】泥点三池与会员计费后端设计-2026-10-02.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index d34df3264..b23958b0e 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -272,6 +272,9 @@ pub struct RuntimeProfileMembershipUpgradeQuote { - `quote_runtime_profile_membership_upgrade(input) -> Result`: 入参 `RuntimeProfileMembershipUpgradeQuoteInput`(当前档位 / 周期、目标档位 / 周期、期序号 / 期总数、 本期窗口、当前月度余额、`now`);金额向上取到分、补点向下取整;月付 / 年付分支见 §6;年价取目录 `year_price_cents`。 +- 报价函数对数值入参做防御式自检:`1 <= cycle_index <= cycle_count`、`cycle_started_at <= now`、 + `cycle_started_at <= cycle_resets_at`,越界返回 `invalid_quote_input`(`InvalidQuoteInput`), + 不再被 `saturating_sub` 静默夹取;生产入口从会员行构造的入参恒满足这些不变量。 - `check_runtime_profile_membership_upgrade_allowed(...) -> Result<(), rejection>`: §3.4 允许操作矩阵的唯一实现(非会员不可升级 / 不支持月转年与年转月 / 同档与降档拒绝 / 目标不可购买拒绝)。 - `membership_cycle_remaining_ratio_ppm(cycle_started_at, cycle_resets_at, now) -> u32`:本期剩余比例(ppm,向下取整)。 From 047e6d72e494227c9fc3818b72579ee619b03a1b 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 20:05:51 +0800 Subject: [PATCH 034/232] =?UTF-8?q?=E9=87=8D=E6=9E=84(=E8=AE=A1=E8=B4=B9):?= =?UTF-8?q?=20=E4=BC=9A=E5=91=98=E6=A1=A3=E4=BD=8D=20rank=20=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E6=94=B9=E4=B8=BA=E5=8F=AF=E5=A4=B1=E8=B4=A5=E8=BF=94?= =?UTF-8?q?=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runtime_profile_membership_plan_rank 返回 Option,目录缺行时返回 None 去掉 unwrap_or(0),不再把目录缺失伪装成 Normal 而误报 NotUpgrade plan_rank_follows_catalog_order 测试断言同步改为 Some(..) Co-authored-by: Junie --- .../module-runtime/src/membership/catalog.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/server-rs/crates/module-runtime/src/membership/catalog.rs b/server-rs/crates/module-runtime/src/membership/catalog.rs index e7d2860e3..681cf9aa9 100644 --- a/server-rs/crates/module-runtime/src/membership/catalog.rs +++ b/server-rs/crates/module-runtime/src/membership/catalog.rs @@ -173,10 +173,11 @@ pub fn resolve_runtime_profile_membership_plan_snapshot( } /// 升级比较基准:目录里的 `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) +/// +/// 返回 `Option` 是为了把「目录缺行」显式化:不再用 `unwrap_or(0)` 把缺失伪装成 `Normal`, +/// 否则缺失会被 `check_..._allowed` 误判成「目标不高于当前」→ 误报 `NotUpgrade`。 +pub fn runtime_profile_membership_plan_rank(plan: RuntimeProfileMembershipPlan) -> Option { + resolve_runtime_profile_membership_plan_row(plan).map(|row| row.rank) } /// 只有在售档位可以下单:`Normal` 与下架档位都不允许购买。 @@ -371,15 +372,15 @@ mod tests { fn plan_rank_follows_catalog_order() { assert_eq!( runtime_profile_membership_plan_rank(RuntimeProfileMembershipPlan::Normal), - 0 + Some(0) ); assert_eq!( runtime_profile_membership_plan_rank(RuntimeProfileMembershipPlan::Plus), - 2 + Some(2) ); assert_eq!( runtime_profile_membership_plan_rank(RuntimeProfileMembershipPlan::Max), - 4 + Some(4) ); } From 51c5784c46e4778ba955db6364a2fb1ca5d86662 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 20:06:01 +0800 Subject: [PATCH 035/232] =?UTF-8?q?=E6=96=87=E6=A1=A3(=E8=AE=A1=E8=B4=B9):?= =?UTF-8?q?=20=E5=90=8C=E6=AD=A5=E4=BC=9A=E5=91=98=E6=A1=A3=E4=BD=8D=20ran?= =?UTF-8?q?k=20=E5=8F=AF=E5=A4=B1=E8=B4=A5=E8=BF=94=E5=9B=9E=E8=AF=B4?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR §3 更新 runtime_profile_membership_plan_rank 签名为 Option 说明目录缺行时返回 None,不再用 unwrap_or(0) 伪装成 Normal Co-authored-by: Junie --- .../【技术设计】泥点三池与会员计费后端设计-2026-10-02.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index b23958b0e..38420eef9 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -254,8 +254,9 @@ pub struct RuntimeProfileMembershipUpgradeQuote { `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` 与下架档位都不可购买)。 +- `runtime_profile_membership_plan_rank(plan) -> Option` / `is_runtime_profile_membership_plan_purchasable(plan) -> bool`: + 升级比较与可购买判定的唯一入口;`rank` 缺行时返回 `None`(不再用 `unwrap_or(0)` 把缺失伪装成 `Normal`), + `Normal` 与下架档位都不可购买。 `RuntimeProfileMembershipSnapshot` / `RuntimeProfileMembershipRecord`: `tier` → `plan`(枚举),删 `cycle_period_days`,新增 `cycle_index` / `cycle_count` / `cycle_kind`。 From bc3d8dc7da24d4673bea284a2fa2c86dbd7c5b82 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 20:06:07 +0800 Subject: [PATCH 036/232] =?UTF-8?q?=E6=B3=A8=E9=87=8A(=E8=AE=A1=E8=B4=B9):?= =?UTF-8?q?=20=E8=AE=B0=E5=BD=95=E4=BC=9A=E5=91=98=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E9=87=8D=E5=BB=BA=E6=9A=82=E4=B8=8D=E7=BC=93=E5=AD=98=E7=9A=84?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runtime_profile_membership_plan_catalog 加 TODO,说明每次重建只有 5 行、开销可忽略 按作者意见标记 wont fix,暂不改 LazyLock / &'static 缓存 Co-authored-by: Junie --- server-rs/crates/module-runtime/src/membership/catalog.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server-rs/crates/module-runtime/src/membership/catalog.rs b/server-rs/crates/module-runtime/src/membership/catalog.rs index 681cf9aa9..d531292c5 100644 --- a/server-rs/crates/module-runtime/src/membership/catalog.rs +++ b/server-rs/crates/module-runtime/src/membership/catalog.rs @@ -62,6 +62,8 @@ pub struct RuntimeProfileMembershipPlanSnapshot { /// [`MEMBERSHIP_UNLIMITED_CONCURRENCY`](crate::MEMBERSHIP_UNLIMITED_CONCURRENCY) 表示「不设上限」。 /// `Normal` 是非会员占位档位,不可购买,因此 `enabled = false`。 pub fn runtime_profile_membership_plan_catalog() -> Vec { + // TODO(membership-catalog): 每次解析都重建目录并重新分配每行字符串(maybe too many cp), + // 但只有 5 行、开销可忽略,按作者意见暂不修复(wont fix,评审第 10 项)。 use RuntimeProfileMembershipModelAccess::{Basic, Full}; use RuntimeProfileMembershipPlan::{Max, Normal, Plus, Pro, Starter}; From 06eb4e668c9d5f23493765b61b3a43726a995076 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 20:26:48 +0800 Subject: [PATCH 037/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E8=AE=A1=E8=B4=B9):?= =?UTF-8?q?=20=E5=8D=87=E7=BA=A7=E6=8A=A5=E4=BB=B7=E6=80=BB=E6=9C=9F?= =?UTF-8?q?=E6=95=B0=E6=94=B9=E7=94=B1=E5=91=A8=E6=9C=9F=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E6=8E=A8=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 RuntimeProfileMembershipUpgradeQuoteInput.cycle_count 入参,改由 target.cycle_kind.cycle_count() 推导(月付 1 / 年付 12),杜绝入参期数与周期类型不一致导致按错误期数计费 - 报价数值自检的期号上界改为相对推导出的总期数判断 - spacetime-module 构造报价入参时不再传 cycle_count - 同步升级报价单测:年付 12 期、当前第 3 期 → 剩余完整月数 9,金额与测试夹具一并修正 - 同步设计文档 §3 入参与自检说明、§5.3 年付示例(remainingFullMonths 9 / amountCents 158334) Co-authored-by: Junie --- ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 16 ++++--- .../module-runtime/src/membership/upgrade.rs | 48 ++++++++++--------- .../src/runtime/active/profile.rs | 1 - 3 files changed, 35 insertions(+), 30 deletions(-) diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index 38420eef9..dc08098a8 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -271,15 +271,17 @@ pub struct RuntimeProfileMembershipUpgradeQuote { `beijing_local_micros(年月日, 当日微秒)`、`membership_expires_at(anchor, cycle_kind)`、 `membership_cycle_remaining_micros(resets_at, now)`。 - `quote_runtime_profile_membership_upgrade(input) -> Result`: - 入参 `RuntimeProfileMembershipUpgradeQuoteInput`(当前档位 / 周期、目标档位 / 周期、期序号 / 期总数、 + 入参 `RuntimeProfileMembershipUpgradeQuoteInput`(当前档位 / 周期、目标档位 / 周期、期序号、 本期窗口、当前月度余额、`now`);金额向上取到分、补点向下取整;月付 / 年付分支见 §6;年价取目录 `year_price_cents`。 -- 报价函数对数值入参做防御式自检:`1 <= cycle_index <= cycle_count`、`cycle_started_at <= now`、 - `cycle_started_at <= cycle_resets_at`,越界返回 `invalid_quote_input`(`InvalidQuoteInput`), - 不再被 `saturating_sub` 静默夹取;生产入口从会员行构造的入参恒满足这些不变量。 + 本次购买总期数**不是入参**:由目标周期类型推导(`target.cycle_kind.cycle_count()`,月付 1 / 年付 12), + 杜绝「入参期数与周期类型不一致」导致按错误期数计费。 +- 报价函数对数值入参做防御式自检:`1 <= cycle_index <= cycle_count`(`cycle_count` 由上一步推导)、 + `cycle_started_at <= now`、`cycle_started_at <= cycle_resets_at`,越界返回 `invalid_quote_input` + (`InvalidQuoteInput`),不再被 `saturating_sub` 静默夹取;生产入口从会员行构造的入参恒满足这些不变量。 - `check_runtime_profile_membership_upgrade_allowed(...) -> Result<(), rejection>`: §3.4 允许操作矩阵的唯一实现(非会员不可升级 / 不支持月转年与年转月 / 同档与降档拒绝 / 目标不可购买拒绝)。 - `membership_cycle_remaining_ratio_ppm(cycle_started_at, cycle_resets_at, now) -> u32`:本期剩余比例(ppm,向下取整)。 -- **「后续完整月数」= `cycle_count − cycle_index`**,不从 `cycle_resets_at` 重新锚定推算: +- **「后续完整月数」= `cycle_count − cycle_index`**(`cycle_count` 由周期类型推导),不从 `cycle_resets_at` 重新锚定推算: 月末夹取后的日期(1/31 锚点得到 2/28)不再是原始开通日,重锚会把后续月份漂移成 3/28。 - `apply_membership_upgrade(row, quote) -> row'`:`cycle_remaining += 补点`、 `cycle_granted_points += 补点`、`plan` 更新,刷新日与到期日不变。 @@ -365,12 +367,12 @@ flowchart TD "cycleKind": "yearly", "cycleIndex": 3, "cycleCount": 12, - "amountCents": 108334, + "amountCents": 158334, "grantedPointsDelta": 1250, "monthlyBalanceAfter": 1865, "expiresAt": "...", "cycleResetsAt": "...", - "remainingFullMonths": 6 + "remainingFullMonths": 9 } ``` diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index 175e77a06..f74a5a65b 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -9,6 +9,7 @@ //! - 取整:金额**向上**取到分,补点**向下**取整。 //! - 有效期、刷新日、期数都不变;已用点不返还,永久泥点不动。 //! +//! 「本次购买总期数」由周期类型推导(`cycle_kind.cycle_count()`:月付 1 / 年付 12),不作为入参; //! 「后续完整月数」取 `cycle_count − cycle_index`,**不**从 `cycle_resets_at` 重新锚定推算: //! 月末夹取后的日期(如 1/31 锚点得到 2/28)不再是原始开通日,重锚会漂移成 3/28。 //! 「本期剩余比例」用本期实际时长做分母,单位取百万分之一(ppm)。 @@ -28,14 +29,15 @@ const MONTHS_PER_YEAR: u128 = 12; /// /// 两个套餐快照由调用方从 `profile_membership_plan` 目录表解析后传入, /// 这样价格与权益的唯一真相源始终是那张可被后台改价的表,而不是编译进代码的种子。 +/// +/// 本次购买总期数**不是**入参:它由目标周期类型推导(`target.cycle_kind.cycle_count()`, +/// 月付 1 / 年付 12),避免调用方传入与周期类型不一致的期数而算出错误金额。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeProfileMembershipUpgradeQuoteInput { pub current: RuntimeProfileMembershipPlanSnapshot, pub target: RuntimeProfileMembershipPlanSnapshot, /// 当前期序号(1-based)。 pub cycle_index: u32, - /// 本次购买总期数(月付 1 / 年付 12)。 - pub cycle_count: u32, pub cycle_started_at_micros: i64, pub cycle_resets_at_micros: i64, pub expires_at_micros: i64, @@ -122,12 +124,14 @@ pub fn check_runtime_profile_membership_upgrade_allowed( /// 报价入参的数值自检。生产入口从会员行构造入参,这些不变量恒成立; /// 在此显式校验是为了不让越界值被 `saturating_sub` 静默夹取后算出错误金额。 /// -/// 刻意不做的两项(见 review 第 12 项):`cycle_count` 与周期类型的对齐(月付 1 / 年付 12)、 -/// `now` 的窗口上界——已过期报价按「剩余比例为 0」返回,属既有契约。 +/// 总期数不在入参里,而是由目标周期类型推导(`cycle_kind.cycle_count()`),所以「期号越界」 +/// 相对该推导值判断,`cycle_count` 与周期类型天然对齐。`now` 只校验下界——已过期报价按 +/// 「剩余比例为 0」返回,属既有契约。 fn validate_runtime_profile_membership_upgrade_quote_input( input: &RuntimeProfileMembershipUpgradeQuoteInput, ) -> Result<(), RuntimeProfileMembershipUpgradeRejection> { - let cycle_index_out_of_range = input.cycle_index == 0 || input.cycle_index > input.cycle_count; + let cycle_count = input.target.cycle_kind.cycle_count(); + let cycle_index_out_of_range = input.cycle_index == 0 || input.cycle_index > cycle_count; let window_out_of_order = input.cycle_resets_at_micros < input.cycle_started_at_micros; let now_before_window = input.now_micros < input.cycle_started_at_micros; if cycle_index_out_of_range || window_out_of_order || now_before_window { @@ -146,7 +150,8 @@ pub fn quote_runtime_profile_membership_upgrade( let current = input.current; let target = input.target; - let remaining_full_months = input.cycle_count.saturating_sub(input.cycle_index); + let cycle_count = target.cycle_kind.cycle_count(); + let remaining_full_months = cycle_count.saturating_sub(input.cycle_index); let remaining_ratio_ppm = membership_cycle_remaining_ratio_ppm( input.cycle_started_at_micros, input.cycle_resets_at_micros, @@ -227,7 +232,6 @@ mod tests { RuntimeProfileMembershipCycleKind::Yearly, ), cycle_index: 3, - cycle_count: 9, cycle_started_at_micros, cycle_resets_at_micros, expires_at_micros: beijing(2027, 3, 20, 9), @@ -251,7 +255,6 @@ mod tests { RuntimeProfileMembershipCycleKind::Monthly, ), cycle_index: 1, - cycle_count: 1, cycle_remaining_points: 615, ..base_input() }) @@ -265,14 +268,15 @@ mod tests { #[test] fn yearly_upgrade_matches_the_spec_example() { - // 中文注释:Plus 年 → Pro 年,剩 6 个完整月 + 半个当前月 → 补付 ¥1083.34、补点 1250。 + // 中文注释:Plus 年 → Pro 年,年付 12 期、当前第 3 期 → 剩 9 个完整月 + 半个当前月, + // 补付 ¥1583.34、补点 1250。 let quote = quote_runtime_profile_membership_upgrade(base_input()).expect("年付升级应可报价"); - assert_eq!(quote.amount_cents, 108_334); + assert_eq!(quote.amount_cents, 158_334); assert_eq!(quote.granted_points_delta, 1_250); assert_eq!(quote.monthly_balance_after, 1_865); - assert_eq!(quote.remaining_full_months, 6); + assert_eq!(quote.remaining_full_months, 9); assert_eq!(quote.remaining_ratio_ppm, 500_000); } @@ -315,8 +319,8 @@ mod tests { // 中文注释:此处已把 `input` 整体移入调用,上面的比例断言必须先于本行执行。 assert_eq!(quote.remaining_ratio_ppm, 0); assert_eq!(quote.granted_points_delta, 0); - // 中文注释:仅剩 6 个完整月,本期零头为 0,因此只收 6/12 的价差并向下取整补点。 - assert_eq!(quote.amount_cents, 100_000); + // 中文注释:仅剩 9 个完整月,本期零头为 0,因此只收 9/12 的价差并向下取整补点。 + assert_eq!(quote.amount_cents, 150_000); } #[test] @@ -376,7 +380,7 @@ mod tests { ); assert_eq!( quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { - cycle_index: base_input().cycle_count + 1, + cycle_index: RuntimeProfileMembershipCycleKind::Yearly.cycle_count() + 1, ..base_input() }), Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) @@ -417,8 +421,8 @@ mod tests { #[test] fn continuing_upgrade_compares_against_the_current_plan() { - // 中文注释:Starter → Plus → Pro 时,第二次按 Plus vs Pro 计算(¥1083.34), - // 而不是按 Starter 直接跳 Pro(¥1408.34)。 + // 中文注释:Starter → Plus → Pro 时,第二次按 Plus vs Pro 计算(¥1583.34), + // 而不是按 Starter 直接跳 Pro(¥2058.34)。 let starter_to_plus = quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { current: snapshot( @@ -454,17 +458,17 @@ mod tests { }) .expect("Plus → Pro 应可报价"); - assert_eq!(starter_to_plus.amount_cents, 32_500); - assert_eq!(starter_to_pro.amount_cents, 140_834); - assert_eq!(plus_to_pro.amount_cents, 108_334); + assert_eq!(starter_to_plus.amount_cents, 47_500); + assert_eq!(starter_to_pro.amount_cents, 205_834); + assert_eq!(plus_to_pro.amount_cents, 158_334); assert_ne!(starter_to_pro.amount_cents, plus_to_pro.amount_cents); } #[test] fn yearly_amount_rounds_up_to_cent_and_points_round_down() { // 中文注释:本期实际时长 3 天、已过 2 天 → 剩余比例 333_333 ppm(向下取整); - // 金额 200000 × (6 + 0.333333) / 12 = 105555.55 分 → 向上到 105556; - // 补点 2500 × 0.333333 = 833.33 → 向下取整为 833。 + // 年付 12 期、当前第 3 期 → 剩 9 个完整月,金额 200000 × (9 + 0.333333) / 12 + // = 155555.55 分 → 向上到 155556;补点 2500 × 0.333333 = 833.33 → 向下取整为 833。 let started = beijing(2026, 6, 20, 9); let quote = quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { @@ -476,7 +480,7 @@ mod tests { .expect("年付升级应可报价"); assert_eq!(quote.remaining_ratio_ppm, 333_333); - assert_eq!(quote.amount_cents, 105_556); + assert_eq!(quote.amount_cents, 155_556); assert_eq!(quote.granted_points_delta, 833); } } 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 7a1867ce7..86aa6956b 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -9067,7 +9067,6 @@ fn membership_upgrade_quote_input( current: current.clone(), target: target.clone(), cycle_index: row.cycle_index.max(1), - cycle_count: row.cycle_count.max(1), cycle_started_at_micros: row .cycle_started_at .map(|value| value.to_micros_since_unix_epoch()) From f631a2f06b4d0beffec123c6a8a2b30a680d88d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 11:15:11 +0800 Subject: [PATCH 038/232] =?UTF-8?q?=E6=B8=85=E7=90=86=E5=A4=B1=E6=95=88?= =?UTF-8?q?=E7=9A=84=20generated=20TS=20=E8=A6=86=E7=9B=96=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除 .prettierrc.json 中指向 packages/shared/src/contracts/generated/**/*.ts 的 overrides 该目录已加入 .prettierignore,覆盖项不再生效,只会误导维护者 Co-authored-by: Junie --- .prettierrc.json | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/.prettierrc.json b/.prettierrc.json index c4f9a8e5d..7d2081a95 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,14 +1,5 @@ { "singleQuote": true, "semi": true, - "trailingComma": "all", - "overrides": [ - { - "files": "packages/shared/src/contracts/generated/**/*.ts", - "options": { - "printWidth": 1000, - "singleQuote": false - } - } - ] + "trailingComma": "all" } From d49c52735191e089980bee5b0776c017b9ff2961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 11:15:12 +0800 Subject: [PATCH 039/232] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E9=80=80=E5=87=BA?= =?UTF-8?q?=E4=B8=8E=E7=99=BB=E5=BD=95=E6=97=B6=E9=87=8D=E7=BD=AE=E4=BC=9A?= =?UTF-8?q?=E5=91=98=E6=A1=A3=E4=BD=8D=E4=BF=9D=E5=AD=98=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdminApp.tsx 的 clearSession 与 handleLogin 中补上 setMembershipPlanResult(null) 与其余三个结果状态保持一致,避免换管理员登录后展示上一会话的「最近保存」面板 Co-authored-by: Junie --- apps/admin-web/src/app/AdminApp.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index e89cc7b7c..8a0d9a3cb 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -87,6 +87,7 @@ export function AdminApp() { setTaskConfigResult(null); setProfileWalletConfigResult(null); setRechargeProductResult(null); + setMembershipPlanResult(null); setStatus('guest'); setLoginNotice(message); }, []); @@ -177,6 +178,7 @@ export function AdminApp() { setTaskConfigResult(null); setProfileWalletConfigResult(null); setRechargeProductResult(null); + setMembershipPlanResult(null); setLoginNotice(''); setStatus('authenticated'); }, From 848f0b54c80803e4edb3e61767c09776f5d29c62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 11:15:13 +0800 Subject: [PATCH 040/232] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E4=BC=9A=E5=91=98=E6=A1=A3=E4=BD=8D=E8=A1=A8=E5=8D=95=E7=9A=84?= =?UTF-8?q?=E4=BB=B7=E6=A0=BC=E4=B8=8B=E9=99=90=E4=B8=8E=E7=BC=BA=E6=A1=A3?= =?UTF-8?q?=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 月价 / 年价输入框改为 min={isPurchasable ? 1 : 0},normal 档(0 价)不再被浏览器原生校验拦住 下拉选到目录中不存在的档位时清空表单并置空 rankLabel,避免提交上一档的陈旧值 Co-authored-by: Junie --- .../src/pages/AdminMembershipPlanPage.tsx | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx index 883f769b2..f5df76933 100644 --- a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx +++ b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx @@ -182,6 +182,20 @@ export function AdminMembershipPlanPage({ setRankLabel(String(entry.rank)); } + // 目录里还没有这一档时清空表单,避免把上一档的价格 / 权益误当成它的值提交。 + function clearForm(nextPlan: ProfileMembershipPlan) { + setPlan(nextPlan); + setTitle(''); + setMonthPriceCents('0'); + setYearPriceCents('0'); + setPeriodPoints('0'); + setModelAccess('basic'); + setConcurrentJobLimit('1'); + setEnabled(false); + setSortOrder('0'); + setRankLabel(''); + } + const isPurchasable = plan !== 'normal'; return ( @@ -216,7 +230,7 @@ export function AdminMembershipPlanPage({ if (entry) { fillForm(entry); } else { - setPlan(nextPlan); + clearForm(nextPlan); } }} > @@ -253,7 +267,7 @@ export function AdminMembershipPlanPage({ Date: Sat, 3 Oct 2026 11:15:20 +0800 Subject: [PATCH 041/232] =?UTF-8?q?=E5=85=85=E5=80=BC=E5=95=86=E5=93=81?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=8F=AA=E5=B1=95=E7=A4=BA=E6=B3=A5=E7=82=B9?= =?UTF-8?q?=E5=95=86=E5=93=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshProducts 过滤 response.entries,仅保留 kind === 'points' 避免历史会员商品行被渲染成 0 + 0 泥点,且保存时必然被后端拒绝 Co-authored-by: Junie --- apps/admin-web/src/pages/AdminRechargeProductPage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx index cf95a7030..488284c4b 100644 --- a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx @@ -68,7 +68,10 @@ export function AdminRechargeProductPage({ setListErrorMessage(''); try { const response = await listProfileRechargeProducts(token); - const sortedEntries = sortProducts(response.entries); + // 中文注释:历史会员商品行已迁到「会员档位」页,本页只维护泥点商品,避免误展示为 0 + 0 泥点。 + const sortedEntries = sortProducts( + response.entries.filter((entry) => entry.kind === 'points'), + ); setEntries(sortedEntries); const firstEntry = sortedEntries[0]; if (firstEntry) { From 40e4e6e38428dd7daa6c3c95d69a0d7c2ab5e2f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 11:15:21 +0800 Subject: [PATCH 042/232] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=B3=A5=E7=82=B9?= =?UTF-8?q?=E4=BD=99=E9=A2=9D=E8=A7=86=E5=9B=BE=E4=B8=8E=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E7=9A=84=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveProfileMudPointBalanceView 为 dailyFreeResetPoints 补 ?? 0,避免界面显示 NaN formatBeijingDateTime 改用 hourCycle: 'h23',保证午夜稳定输出 00:00 而非 24:00 Co-authored-by: Junie --- packages/shared/src/utils/mudPoints.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/utils/mudPoints.ts b/packages/shared/src/utils/mudPoints.ts index d20282667..c56568910 100644 --- a/packages/shared/src/utils/mudPoints.ts +++ b/packages/shared/src/utils/mudPoints.ts @@ -19,6 +19,7 @@ export function resolveProfileMudPointBalanceView( return { ...balance, dailyFreePoints: balance.dailyFreePoints ?? 0, + dailyFreeResetPoints: balance.dailyFreeResetPoints ?? 0, monthlyPoints: balance.monthlyPoints ?? 0, permanentPoints: balance.permanentPoints ?? 0, totalPoints: @@ -63,7 +64,7 @@ export function formatMudPointResetAt( day: '2-digit', hour: '2-digit', minute: '2-digit', - hour12: false, + hourCycle: 'h23', }).formatToParts(parsed); const lookup = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? '00'; From ac5a03bf71319fef29cc1d07caf60c6bfb1f8f1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 11:15:23 +0800 Subject: [PATCH 043/232] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E9=92=B1=E5=8C=85=20?= =?UTF-8?q?store=20=E5=8D=95=E6=B5=8B=E7=9A=84=E9=94=AE=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit walletStore.test.ts 去掉已删除的 applyLegacyWalletBalanceSnapshot 断言 使「mudPointBalance 为权威余额」用例与当前 store 实现保持一致 Co-authored-by: Junie --- apps/ai-game-creator-shell/tests/walletStore.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/ai-game-creator-shell/tests/walletStore.test.ts b/apps/ai-game-creator-shell/tests/walletStore.test.ts index 8062796dc..3cd3f64f4 100644 --- a/apps/ai-game-creator-shell/tests/walletStore.test.ts +++ b/apps/ai-game-creator-shell/tests/walletStore.test.ts @@ -50,7 +50,6 @@ describe('useWalletStore', () => { expect(result.current.mudPointBalanceStatus).toBe('ready'); expect(result.current.mudPointBalanceError).toBe(''); expect(Object.keys(result.current).sort()).toEqual([ - 'applyLegacyWalletBalanceSnapshot', 'applyWalletBalanceSnapshot', 'captureWalletBalanceSnapshot', 'legacyWalletBalance', From 119eb7c8b38cd83028df2cbfe90a76e42c5d78d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 11:15:30 +0800 Subject: [PATCH 044/232] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E4=BC=9A=E5=91=98=E6=A1=A3=E4=BD=8D=E4=B8=8E=E5=85=85=E5=80=BC?= =?UTF-8?q?=E5=95=86=E5=93=81=E7=9A=84=E6=A0=A1=E9=AA=8C=E5=92=8C=E6=8A=A5?= =?UTF-8?q?=E9=94=99=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 可购买档位新增 period_points == 0 拒绝;不可购买档位固定使用目录种子的并发上限与模型权限 InvalidRechargeProductTier 拆为 InvalidRechargeProductKind 与 InvalidRechargeProductFields,报错不再误导运营 Co-authored-by: Junie --- .../crates/module-runtime/src/commands.rs | 20 +++++++++++++------ server-rs/crates/module-runtime/src/errors.rs | 10 +++++++--- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 02d11db91..22765aa15 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -410,12 +410,12 @@ pub fn build_runtime_profile_recharge_product_admin_upsert_input( return Err(RuntimeProfileFieldError::InvalidRechargeProductPoints); } if duration_days != 0 || bonus_points != 0 { - return Err(RuntimeProfileFieldError::InvalidRechargeProductTier); + return Err(RuntimeProfileFieldError::InvalidRechargeProductFields); } } // 中文注释:会员商品已迁移到 profile_membership_plan 目录,这里只保留泥点商品。 RuntimeProfileRechargeProductKind::Membership => { - return Err(RuntimeProfileFieldError::InvalidRechargeProductTier); + return Err(RuntimeProfileFieldError::InvalidRechargeProductKind); } } @@ -445,7 +445,8 @@ pub fn build_runtime_profile_membership_plan_admin_list_input( /// 后台会员档位改价 / 改权益入参校验。 /// -/// `normal` 是不可购买的非会员占位档位:它的价格与额度没有意义,一律按 0 落库。 +/// `normal` 是不可购买的非会员占位档位:价格与每期泥点一律按 0 落库; +/// 并发上限与模型权限固定为目录种子里的非会员基线,后台不能改。 #[allow(clippy::too_many_arguments)] pub fn build_runtime_profile_membership_plan_admin_upsert_input( admin_user_id: String, @@ -465,6 +466,9 @@ pub fn build_runtime_profile_membership_plan_admin_upsert_input( normalize_required_string(title).ok_or(RuntimeProfileFieldError::MissingProductTitle)?; if !plan.is_purchasable() { + // 中文注释:非会员基线固定取自目录种子,避免后台把「非会员」改成 0 并发或「不限并发」。 + let baseline = crate::resolve_runtime_profile_membership_plan_row(plan) + .ok_or(RuntimeProfileFieldError::InvalidMembershipPlan)?; return Ok(RuntimeProfileMembershipPlanAdminUpsertInput { admin_user_id, plan, @@ -472,15 +476,19 @@ pub fn build_runtime_profile_membership_plan_admin_upsert_input( month_price_cents: 0, year_price_cents: 0, period_points: 0, - model_access, - concurrent_job_limit, + model_access: baseline.model_access, + concurrent_job_limit: baseline.concurrent_job_limit, enabled: false, sort_order, updated_at_micros, }); } - if month_price_cents == 0 || year_price_cents == 0 || concurrent_job_limit == 0 { + if month_price_cents == 0 + || year_price_cents == 0 + || period_points == 0 + || concurrent_job_limit == 0 + { return Err(RuntimeProfileFieldError::InvalidMembershipPlan); } diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index f282bd0c4..99e43f685 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -90,7 +90,7 @@ pub enum RuntimeProfileFieldError { InvalidRechargeProductPoints, InvalidRechargeProductDuration, InvalidRechargeProductKind, - InvalidRechargeProductTier, + InvalidRechargeProductFields, InvalidMembershipPlan, MissingPaymentChannel, #[cfg(any())] @@ -185,8 +185,12 @@ impl std::fmt::Display for RuntimeProfileFieldError { Self::InvalidRechargeProductDuration => { f.write_str("会员商品 duration_days 必须大于 0") } - Self::InvalidRechargeProductKind => f.write_str("充值商品类型无效"), - Self::InvalidRechargeProductTier => f.write_str("会员商品 tier 无效"), + Self::InvalidRechargeProductKind => { + f.write_str("充值商品类型无效:会员商品已迁移到会员档位目录") + } + Self::InvalidRechargeProductFields => { + f.write_str("泥点商品不支持 duration_days 与 bonus_points 字段") + } Self::InvalidMembershipPlan => { f.write_str("会员档位配置无效:月价、年价、每期泥点与并发上限必须有效") } From 92917f57c9e54938fc9d059b3328944ad31427ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 11:15:34 +0800 Subject: [PATCH 045/232] =?UTF-8?q?=E5=8D=87=E7=BA=A7=E6=8A=A5=E4=BB=B7?= =?UTF-8?q?=E9=81=87=E5=88=B0=E7=9B=AE=E5=BD=95=E5=80=92=E6=8C=82=E6=97=B6?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quote_runtime_profile_membership_upgrade 用 checked_sub 替换 saturating_sub,价格或每期泥点倒挂时返回 InvalidQuoteInput 新增 inverted_catalog_price_and_points_are_rejected 覆盖原本会算成 0 元升级的场景 Co-authored-by: Junie --- .../module-runtime/src/membership/upgrade.rs | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index f74a5a65b..db3474798 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -158,8 +158,15 @@ pub fn quote_runtime_profile_membership_upgrade( input.now_micros, ); - let price_delta_cents = target.price_cents.saturating_sub(current.price_cents); - let points_delta = target.period_points.saturating_sub(current.period_points); + // 目录价格 / 每期泥点必须严格随 rank 递增;倒挂时宁可报错,也不算出 0 元升级。 + let price_delta_cents = target + .price_cents + .checked_sub(current.price_cents) + .ok_or(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput)?; + let points_delta = target + .period_points + .checked_sub(current.period_points) + .ok_or(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput)?; let (amount_cents, granted_points_delta) = match target.cycle_kind { RuntimeProfileMembershipCycleKind::Monthly => (price_delta_cents, points_delta), @@ -401,6 +408,41 @@ mod tests { ); } + #[test] + fn inverted_catalog_price_and_points_are_rejected() { + // 中文注释:后台把更高档位定得更便宜 / 给更少每期泥点时,报价必须报错, + // 而不是被 saturating_sub 静默算成 0 元升级。 + let current = snapshot( + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipCycleKind::Yearly, + ); + let mut target = snapshot( + RuntimeProfileMembershipPlan::Max, + RuntimeProfileMembershipCycleKind::Yearly, + ); + + target.price_cents = current.price_cents - 1; + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current: current.clone(), + target: target.clone(), + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + + target.price_cents = current.price_cents + 1; + target.period_points = current.period_points - 1; + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current, + target, + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + } + #[test] fn cycle_kind_change_is_rejected() { assert_eq!( From 6d04f88f415ce20e08de5be1c09555017bcf5b32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 11:15:37 +0800 Subject: [PATCH 046/232] =?UTF-8?q?=E8=AE=A9=E5=85=85=E5=80=BC=E5=95=86?= =?UTF-8?q?=E5=93=81=E9=BB=98=E8=AE=A4=E6=A1=A3=E8=BF=81=E7=A7=BB=E8=AF=86?= =?UTF-8?q?=E5=88=AB=E6=97=A7=E8=BF=81=E7=A7=BB=E9=81=97=E7=95=99=E7=9A=84?= =?UTF-8?q?=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_default_point_product_migration 同时识别停用的「首充双倍」行(赠送为 0 或等于面额)与在售的「首充加赠」行 更新迁移单测:disabled_seed 按旧迁移后的真实状态构造,并补充「首充加赠」用例 Co-authored-by: Junie --- .../src/runtime/active/profile.rs | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) 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 86aa6956b..52a153ab1 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -3729,9 +3729,9 @@ mod tests { assert_eq!(migration.description, "180泥点"); assert!(migration.enabled); - // 中文注释:¥128 / ¥328 两档旧版本是停用的首充行,现在改为在售且无赠送。 + // 中文注释:¥128 / ¥328 两档旧版本是停用的首充行,上一版迁移把赠送保留成面额。 let disabled_seed = ProfileRechargeProductConfig { - bonus_points: 0, + bonus_points: 1_280, badge_label: "首充双倍".to_string(), enabled: false, ..build_row(1_280) @@ -3741,6 +3741,21 @@ mod tests { assert!(migration.enabled); assert_eq!(migration.bonus_points, 0); + // 中文注释:上一版迁移已把 180 / 300 / 680 改成在售的「首充加赠」行,也要归一。 + let first_charge_bonus_seed = ProfileRechargeProductConfig { + bonus_points: 90, + badge_label: "首充加赠".to_string(), + description: "首充加赠90泥点".to_string(), + enabled: true, + ..build_row(180) + }; + let migration = resolve_default_point_product_migration(&first_charge_bonus_seed) + .expect("「首充加赠」行应被归一"); + assert!(migration.enabled); + assert_eq!(migration.bonus_points, 0); + assert_eq!(migration.badge_label, ""); + assert_eq!(migration.description, "180泥点"); + // 中文注释:后台改过的行(updated_by 已变)一律不动。 let mut admin_row = build_row(300); admin_row.updated_by = "admin-1".to_string(); @@ -8765,11 +8780,14 @@ fn resolve_default_point_product_migration( && row.bonus_points == row.points_amount && row.badge_label == "首充双倍" && row.description == format!("首充送{}泥点", row.points_amount); - // ¥128 / ¥328 两档曾是「停用的首充双倍行」,现在改为在售且无赠送。 - let is_legacy_disabled_seed = - !row.enabled && row.bonus_points == 0 && row.badge_label == "首充双倍"; + // ¥128 / ¥328 两档曾是「停用的首充双倍行」:旧迁移把赠送保留成面额,所以两种形状都要认。 + let is_legacy_disabled_seed = !row.enabled + && row.badge_label == "首充双倍" + && (row.bonus_points == 0 || row.bonus_points == row.points_amount); + // 180 / 300 / 680 三档被上一版迁移改成了在售的「首充加赠」行,也要归一。 + let is_legacy_first_charge_bonus_seed = row.enabled && row.badge_label == "首充加赠"; - if is_legacy_bonus_seed || is_legacy_disabled_seed { + if is_legacy_bonus_seed || is_legacy_disabled_seed || is_legacy_first_charge_bonus_seed { return Some(canonical); } None From ff1b18c7846b4cc180a663fcff378f09ff49826e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 11:48:18 +0800 Subject: [PATCH 047/232] =?UTF-8?q?=E6=9B=B4=E6=96=B0=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=E5=8F=B0=E6=94=AF=E6=92=91=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设计文档 §5.6 补充用户详情会员状态、月度泥点命名与表浏览器标签 - 设计文档 §10.2 前后端清单补登订单会员快照与用户会员状态可视化 - 决策记录登记 2026-10-03 后台会员计费运维面补全决策 Co-authored-by: Junie --- docs/project-memory/shared-memory/decision-log.md | 11 +++++++++++ ...�技术设计】泥点三池与会员计费后端设计-2026-10-02.md | 7 ++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5016cbdb5..8cf38d7eb 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9405,3 +9405,14 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证:`npm run agc:bundled-resources:test`(18 passed,含 sidecar 归位、跳过规则、上游缺失、版本漂移、目录被占);`npm run agc:bundled-resources:check`、`check-config.mjs`、`cargo test --bin genarrative-ai-game-creator-shell package_layout::tests`、`cargo check --no-default-features`、`cargo fmt --check`、`check:encoding`、eslint/prettier 全部通过;Windows 真机准备步骤 staging 24 个文件(含 243MB `claude.exe`)后 `cargo check` 不再出现构建期写入。 - 边界(未验证):macOS 真机的 sidecar 加载与 `check-macos-bundle.mjs` 包内容门禁未在本机验证;Linux 门禁按新配置不再要求 sidecar 资源,需 CI 实跑确认转绿。 - 关联:issue #519、master `fb130d184`、`docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md`、CI run 3083。 + +## 2026-10-03 后台补全会员计费运维面:订单快照、用户会员状态、三池命名与表标签 + +- 背景:`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §5.6 声称「订单列表 / 退款人工复核读取 §2.4 快照」,但管理端 `AdminRechargeOrderEntryPayload` 没有任何会员字段;用户详情不展示会员档位与账期,钱包月度池仍叫「会员限时」,通用表浏览器缺 `profile_membership_plan` 标签。 +- 决策(订单快照透出):新增 `AdminMembershipOrderChangePayload`(变更类型 / 前后档位 / 前后账期 / 前后每期泥点 / 补点增量 / 期数 / 前后到期与重置 / 价格拆分 JSON)挂到 `AdminRechargeOrderEntryPayload.membershipChange`,由 `map_membership_order_change` 从 module-runtime 既有的 `RuntimeProfileMembershipOrderChangeSnapshot` 直接映射;非会员订单为 `null`。只读,不改下单 / 退款语义。 +- 决策(用户会员状态):`AdminUserDetailResponse` 新增 `membership: AdminProfileMembershipPayload`,api-server 复用 `get_profile_recharge_center` 的 `membership` 投影,不再新开一条 admin 专用读路径。 +- 决策(三池命名只在契约 / UI 层):`AdminProfileWalletPayload.membership_limited_points` 改名为 `monthly_points`(TS `monthlyPoints`),后台钱包区块「会员限时」改为「月度泥点」。**SpacetimeDB 列名 `membership_limited_points` 与生成绑定保持不动**——持久化改名属破坏性变更,须先确认迁移计划。 +- 决策(展示复用):新建 `apps/admin-web/src/config/membershipDisplay.ts` 收敛档位 / 账期展示名与 `format*`,`AdminMembershipPlanPage`、`AdminRechargeOrderPage`、`AdminUserDetailDialog` 共用,不再各留一份 `planLabels`。 +- 影响范围:`server-rs/crates/shared-contracts/src/admin.rs`、`server-rs/crates/api-server/src/admin_recharge.rs`、`apps/admin-web/src/{api/adminApiTypes.ts,config/membershipDisplay.ts,pages/AdminMembershipPlanPage.tsx,pages/AdminRechargeOrderPage.tsx,components/AdminUserDetailDialog.tsx}`、`scripts/admin-web-fake-api.mjs`、设计文档 §5.6/§10.2、本文件。 +- 验证:`cargo check -p api-server`;`npm run admin-web:typecheck`;`npx vitest run apps/admin-web` → 26 files / 248 tests passed(新增「会员订单在发放泥点列展示会员变更快照」及用户详情会员断言)。 +- 边界:`/admin/api/*` 不在 `/api/external/v1` OpenAPI 门禁内,本次未补接口级契约测试,会员快照只在管理端页面用例层面取证。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index dc08098a8..0cff5c155 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -415,6 +415,9 @@ flowchart TD - 后台页:新增「会员档位」页(`AdminMembershipPlanPage`);「充值商品」页收敛为纯泥点商品 (`AdminRechargeProductPage` 不再有会员档位与 `membership_*` 字段)。 - 订单列表 / 退款人工复核读取 §2.4 快照(`membershipChange`,含变更前后档位、周期、补点与有效期)。 +- 用户详情返回单用户会员状态与账期(档位 / 状态 / 账期类型与期数 / 本账期发放与剩余 / 到期 / 重置), + 钱包区块把月度池命名为「月度泥点」。 +- 通用表浏览器补上 `profile_membership_plan` 的中文标签与说明。 ### 5.7 契约生成(ts-rs,已落地) @@ -522,7 +525,9 @@ flowchart TD - 共享组件与平台入口改用三池:`PlatformMudPointWalletEntry` 明细面板并列「永久泥点 / 月度泥点 / 每日免费泥点」, 充值弹层余额读三池合计,`ProfileRechargeCenterResponse` 不再有 `walletBalance`。 - 展示用合计在 `packages/shared/src/utils/mudPoints.ts` 派生(`totalPoints`),月度刷新时刻固定按北京时间格式化。 -- 后台:新增「会员档位」页,充值商品页收敛为纯泥点商品。 +- 后台:新增「会员档位」页,充值商品页收敛为纯泥点商品;订单列表与退款人工复核渲染 + `membershipChange` 快照;用户详情展示会员状态与账期、钱包月度池改名「月度泥点」; + 表浏览器补 `profile_membership_plan` 标签。 未做(依决策保留,不是遗漏): From 7acae0d30836a6d37b4d27980406f87dbb0575d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 11:48:24 +0800 Subject: [PATCH 048/232] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E4=B8=8E=E6=8E=A5=E5=8F=A3=E9=80=8F=E5=87=BA=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=E5=BF=AB=E7=85=A7=E4=B8=8E=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BC=9A=E5=91=98=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared-contracts 新增 AdminMembershipOrderChangePayload 与 AdminProfileMembershipPayload - AdminProfileWalletPayload.membership_limited_points 改名 monthly_points - AdminRechargeOrderEntryPayload 增加 membership_change 快照字段 - AdminUserDetailResponse 增加 membership 字段 - admin_recharge 映射会员订单快照与用户会员中心投影 Co-authored-by: Junie --- .../crates/api-server/src/admin_recharge.rs | 67 ++++++++++++++++--- .../crates/shared-contracts/src/admin.rs | 48 ++++++++++++- 2 files changed, 104 insertions(+), 11 deletions(-) diff --git a/server-rs/crates/api-server/src/admin_recharge.rs b/server-rs/crates/api-server/src/admin_recharge.rs index 950e53d50..88b30e8f2 100644 --- a/server-rs/crates/api-server/src/admin_recharge.rs +++ b/server-rs/crates/api-server/src/admin_recharge.rs @@ -11,6 +11,7 @@ use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileAdminWalletSnapshot, + RuntimeProfileMembershipOrderChangeSnapshot, RuntimeProfileMembershipRecord, RuntimeProfileRechargeOrderAdminEntrySnapshot, RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductKind, RuntimeProfileRechargeRefundHoldSnapshot, RuntimeProfileRechargeRefundHoldStatus, RuntimeProfileRechargeRefundObservationSource, @@ -31,15 +32,16 @@ use platform_wechat::pay::{ use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use shared_contracts::admin::{ - ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE, AdminProfileWalletPayload, - AdminRechargeOrderEntryPayload, AdminRechargeOrderListQuery, AdminRechargeOrderListResponse, - AdminRechargeRefundActionResponse, AdminRechargeRefundExecuteRequest, - AdminRechargeRefundHoldPayload, AdminRechargeRefundManualReviewResolveRequest, - AdminRechargeRefundPayload, AdminRechargeRefundPreviewRequest, - AdminRechargeRefundPreviewResponse, AdminRechargeRefundRegisterRequest, - AdminUserConsumptionProjectionInitializeResponse, AdminUserConsumptionReconcileRequest, - AdminUserConsumptionReconcileResponse, AdminUserDetailQuery, AdminUserDetailResponse, - AdminUserSummaryPayload, AdminWalletManualRestrictionPayload, AdminWalletRestrictionRequest, + ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE, AdminMembershipOrderChangePayload, + AdminProfileMembershipPayload, AdminProfileWalletPayload, AdminRechargeOrderEntryPayload, + AdminRechargeOrderListQuery, AdminRechargeOrderListResponse, AdminRechargeRefundActionResponse, + AdminRechargeRefundExecuteRequest, AdminRechargeRefundHoldPayload, + AdminRechargeRefundManualReviewResolveRequest, AdminRechargeRefundPayload, + AdminRechargeRefundPreviewRequest, AdminRechargeRefundPreviewResponse, + AdminRechargeRefundRegisterRequest, AdminUserConsumptionProjectionInitializeResponse, + AdminUserConsumptionReconcileRequest, AdminUserConsumptionReconcileResponse, + AdminUserDetailQuery, AdminUserDetailResponse, AdminUserSummaryPayload, + AdminWalletManualRestrictionPayload, AdminWalletRestrictionRequest, AdminWalletRestrictionResponse, AdminWechatPaymentCheckPayload, }; use spacetime_client::SpacetimeClientError; @@ -158,6 +160,12 @@ pub async fn admin_get_user_detail( .map_err(|error| spacetime_error_response(&request_context, error))?; let cumulative_recharged_cents = fetch_admin_user_cumulative_recharged_cents(&state, &user.id).await; + let membership_record = state + .spacetime_client() + .get_profile_recharge_center(user.id.clone()) + .await + .map_err(|error| spacetime_error_response(&request_context, error))? + .membership; Ok(json_success_body( Some(&request_context), @@ -175,6 +183,7 @@ pub async fn admin_get_user_detail( cumulative_recharged_cents, can_reconcile_consumption: admin.can(ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE), wallet: map_wallet(wallet_detail.wallet, Some(&admin_display_names)), + membership: map_membership(membership_record), recharge_orders: orders .into_iter() .map(|entry| map_order_entry(entry, Some(summary.clone()))) @@ -979,6 +988,44 @@ fn map_order_entry( remaining_refundable_cents, refund_eligible: block_reason.is_none(), refund_block_reason_code: block_reason, + membership_change: entry.membership_change.map(map_membership_order_change), + } +} + +fn map_membership_order_change( + change: RuntimeProfileMembershipOrderChangeSnapshot, +) -> AdminMembershipOrderChangePayload { + AdminMembershipOrderChangePayload { + change_kind: change.change_kind, + plan_before: change.plan_before, + plan_after: change.plan_after, + cycle_kind_before: change.cycle_kind_before, + cycle_kind_after: change.cycle_kind_after, + period_points_before: change.period_points_before, + period_points_after: change.period_points_after, + granted_points_delta: change.granted_points_delta, + cycle_index: change.cycle_index, + expires_at_before_micros: change.expires_at_before_micros, + expires_at_after_micros: change.expires_at_after_micros, + cycle_resets_at_before_micros: change.cycle_resets_at_before_micros, + cycle_resets_at_after_micros: change.cycle_resets_at_after_micros, + price_breakdown_json: change.price_breakdown_json, + } +} + +fn map_membership(record: RuntimeProfileMembershipRecord) -> AdminProfileMembershipPayload { + AdminProfileMembershipPayload { + plan: record.plan.as_str().to_string(), + status: record.status.as_str().to_string(), + cycle_kind: record.cycle_kind.as_str().to_string(), + cycle_index: record.cycle_index, + cycle_count: record.cycle_count, + cycle_granted_points: record.cycle_granted_points, + cycle_remaining_points: record.cycle_remaining_points, + started_at_micros: record.started_at_micros, + expires_at_micros: record.expires_at_micros, + cycle_resets_at_micros: record.cycle_resets_at_micros, + updated_at_micros: record.updated_at_micros, } } @@ -991,7 +1038,7 @@ fn map_wallet( total_balance: wallet.total_balance, spendable_balance: wallet.spendable_balance, daily_free_points: wallet.daily_free_points, - membership_limited_points: wallet.membership_limited_points, + monthly_points: wallet.membership_limited_points, permanent_points: wallet.permanent_points, held_points: wallet.held_points, refund_debt_points: wallet.refund_debt_points, diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 0309a831d..9ddc50bff 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -1185,7 +1185,8 @@ pub struct AdminProfileWalletPayload { pub total_balance: u64, pub spendable_balance: u64, pub daily_free_points: u64, - pub membership_limited_points: u64, + /// 月度泥点:当前会员账期内发放、随账期重置而清零的泥点;无会员时为 0。 + pub monthly_points: u64, pub permanent_points: u64, pub held_points: u64, pub refund_debt_points: u64, @@ -1233,6 +1234,31 @@ pub struct AdminRechargeRefundHoldPayload { pub updated_at_micros: i64, } +/// 会员购买 / 升级的权益变更快照(只读)。 +/// +/// 与 `profile_recharge_order` 的末位快照列一一对应,供订单列表与退款人工复核还原变更 +/// 前后的档位、账期、补点与有效期。非会员订单为 `None`。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminMembershipOrderChangePayload { + /// `purchase` 或 `upgrade`。 + pub change_kind: String, + pub plan_before: Option, + pub plan_after: Option, + pub cycle_kind_before: Option, + pub cycle_kind_after: Option, + pub period_points_before: Option, + pub period_points_after: Option, + pub granted_points_delta: i64, + pub cycle_index: Option, + pub expires_at_before_micros: Option, + pub expires_at_after_micros: Option, + pub cycle_resets_at_before_micros: Option, + pub cycle_resets_at_after_micros: Option, + /// 订单价格拆分 JSON(目标档位价、当前档位价、实付金额);字符串原样透出供人工核对。 + pub price_breakdown_json: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AdminRechargeOrderEntryPayload { @@ -1262,6 +1288,8 @@ pub struct AdminRechargeOrderEntryPayload { pub remaining_refundable_cents: u64, pub refund_eligible: bool, pub refund_block_reason_code: Option, + /// 会员购买 / 升级快照;非会员订单为 `None`。 + pub membership_change: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -1294,9 +1322,27 @@ pub struct AdminUserDetailResponse { pub cumulative_recharged_cents: Option, pub can_reconcile_consumption: bool, pub wallet: AdminProfileWalletPayload, + pub membership: AdminProfileMembershipPayload, pub recharge_orders: Vec, } +/// 单用户当前会员状态与账期(只读),供后台用户详情判断档位、周期与到期可见性。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminProfileMembershipPayload { + pub plan: String, + pub status: String, + pub cycle_kind: String, + pub cycle_index: u32, + pub cycle_count: u32, + pub cycle_granted_points: u64, + pub cycle_remaining_points: u64, + pub started_at_micros: Option, + pub expires_at_micros: Option, + pub cycle_resets_at_micros: Option, + pub updated_at_micros: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AdminUserConsumptionReconcileRequest { From 76fd96fa063f9d9fe48b04b5d13fc849f53a9aaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 11:48:36 +0800 Subject: [PATCH 049/232] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=AB=AF=E5=91=88=E7=8E=B0=E4=BC=9A=E5=91=98=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E3=80=81=E7=94=A8=E6=88=B7=E4=BC=9A=E5=91=98=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E4=B8=8E=E4=B8=89=E6=B1=A0=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 config/membershipDisplay.ts 收敛档位与账期展示并供三处复用 - 用户详情对话框新增会员区块,钱包月度池改名「月度泥点」 - 充值订单列表与退款弹窗渲染会员变更快照 - 通用表浏览器补 profile_membership_plan 标签 - 补齐管理端类型、用例与本地假数据 Co-authored-by: Junie --- apps/admin-web/src/api/adminApiTypes.ts | 40 ++++++++++++- .../components/AdminUserDetailDialog.test.tsx | 17 +++++- .../src/components/AdminUserDetailDialog.tsx | 51 ++++++++++++++++- .../admin-web/src/config/membershipDisplay.ts | 32 +++++++++++ .../src/pages/AdminDatabaseTablesPage.tsx | 2 + .../src/pages/AdminMembershipPlanPage.tsx | 17 ++---- .../src/pages/AdminRechargeOrderPage.test.tsx | 39 ++++++++++++- .../src/pages/AdminRechargeOrderPage.tsx | 56 ++++++++++++++++++- scripts/admin-web-fake-api.mjs | 15 ++++- 9 files changed, 251 insertions(+), 18 deletions(-) create mode 100644 apps/admin-web/src/config/membershipDisplay.ts diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 598d3e571..f79e3dec4 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -930,7 +930,8 @@ export interface AdminProfileWalletPayload { totalBalance: number; spendableBalance: number; dailyFreePoints: number; - membershipLimitedPoints: number; + /** 月度泥点:当前会员账期内发放、随账期重置而清零的泥点;无会员时为 0。 */ + monthlyPoints: number; permanentPoints: number; heldPoints: number; refundDebtPoints: number; @@ -974,6 +975,25 @@ export interface AdminRechargeRefundHoldPayload { updatedAtMicros: number; } +export interface AdminMembershipOrderChangePayload { + /** `purchase`(新购)或 `upgrade`(补差升级)。 */ + changeKind: 'purchase' | 'upgrade' | string; + planBefore?: ProfileMembershipPlan | null; + planAfter?: ProfileMembershipPlan | null; + cycleKindBefore?: ProfileMembershipCycleKind | null; + cycleKindAfter?: ProfileMembershipCycleKind | null; + periodPointsBefore?: number | null; + periodPointsAfter?: number | null; + grantedPointsDelta: number; + cycleIndex?: number | null; + expiresAtBeforeMicros?: number | null; + expiresAtAfterMicros?: number | null; + cycleResetsAtBeforeMicros?: number | null; + cycleResetsAtAfterMicros?: number | null; + /** 订单价格拆分 JSON(目标档位价、当前档位价、实付金额);人工复核时原样展示。 */ + priceBreakdownJson?: string | null; +} + export interface AdminRechargeOrderEntryPayload { orderId: string; userId: string; @@ -1001,6 +1021,8 @@ export interface AdminRechargeOrderEntryPayload { remainingRefundableCents: number; refundEligible: boolean; refundBlockReasonCode?: string | null; + /** 会员购买 / 升级快照;非会员订单为 null。 */ + membershipChange?: AdminMembershipOrderChangePayload | null; } export interface AdminRechargeOrderListResponse { @@ -1027,9 +1049,25 @@ export interface AdminUserDetailResponse { cumulativeRechargedCents?: number | null; canReconcileConsumption: boolean; wallet: AdminProfileWalletPayload; + membership: AdminProfileMembershipPayload; rechargeOrders: AdminRechargeOrderEntryPayload[]; } +/** 当前会员状态与账期;无会员时为 `normal` 档位、无到期时间。 */ +export interface AdminProfileMembershipPayload { + plan: ProfileMembershipPlan; + status: string; + cycleKind: ProfileMembershipCycleKind; + cycleIndex: number; + cycleCount: number; + cycleGrantedPoints: number; + cycleRemainingPoints: number; + startedAtMicros?: number | null; + expiresAtMicros?: number | null; + cycleResetsAtMicros?: number | null; + updatedAtMicros?: number | null; +} + export interface AdminUserConsumptionReconcileRequest { userId: string; } diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx index 0c14b58be..cd82e6929 100644 --- a/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx +++ b/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx @@ -30,7 +30,7 @@ const wallet: AdminProfileWalletPayload = { totalBalance: 96, spendableBalance: 40, dailyFreePoints: 6, - membershipLimitedPoints: 20, + monthlyPoints: 20, permanentPoints: 70, heldPoints: 5, refundDebtPoints: 25, @@ -54,6 +54,19 @@ const detail: AdminUserDetailResponse = { cumulativeRechargedCents: 128800, canReconcileConsumption: true, wallet, + membership: { + plan: 'plus', + status: 'active', + cycleKind: 'monthly', + cycleIndex: 2, + cycleCount: 1, + cycleGrantedPoints: 500, + cycleRemainingPoints: 320, + startedAtMicros: 1_720_000_000_000_000, + expiresAtMicros: 1_722_592_000_000_000, + cycleResetsAtMicros: 1_722_592_000_000_000, + updatedAtMicros: 1_720_000_000_000_000, + }, rechargeOrders: [ { orderId: 'order-1', @@ -125,6 +138,8 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退 expect(screen.getByText('退款欠账限制')).toBeTruthy(); expect(screen.getByText('25', { selector: 'strong' })).toBeTruthy(); expect(screen.getByText('历史花费')).toBeTruthy(); + expect(screen.getByText('月度泥点')).toBeTruthy(); + expect(screen.getByText('Plus(plus)')).toBeTruthy(); expect(screen.getByText('1234', { selector: 'strong' })).toBeTruthy(); expect(screen.getByText('累计充值')).toBeTruthy(); expect(screen.getByText('¥1288.00')).toBeTruthy(); diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.tsx index 3fadef888..2da40b420 100644 --- a/apps/admin-web/src/components/AdminUserDetailDialog.tsx +++ b/apps/admin-web/src/components/AdminUserDetailDialog.tsx @@ -22,9 +22,14 @@ import { updateAdminWalletRestriction, } from '../api/adminApiClient'; import type { + AdminProfileMembershipPayload, AdminProfileWalletPayload, AdminUserDetailResponse, } from '../api/adminApiTypes'; +import { + formatMembershipCycleKind, + formatMembershipPlan, +} from '../config/membershipDisplay'; import { useAdminWriteConfirm } from './useAdminWriteConfirm'; interface AdminUserDetailDialogProps { @@ -265,6 +270,8 @@ export function AdminUserDetailDialog({ onReconcile={() => void handleConsumptionReconcile()} /> + +
= [ + ['档位', `${formatMembershipPlan(membership.plan)}(${membership.plan})`], + ['状态', membership.status || '-'], + [ + '账期', + `${formatMembershipCycleKind(membership.cycleKind)} 第 ${membership.cycleIndex}/${membership.cycleCount} 期`, + ], + ['本账期发放', `${membership.cycleGrantedPoints} 泥点`], + ['本账期剩余', `${membership.cycleRemainingPoints} 泥点`], + [ + '到期', + membership.expiresAtMicros + ? formatMicros(membership.expiresAtMicros) + : '-', + ], + [ + '账期重置', + membership.cycleResetsAtMicros + ? formatMicros(membership.cycleResetsAtMicros) + : '-', + ], + ]; + return ( +
+ +
+ {metrics.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+
+ ); +} + function formatMoney(cents: number) { return `¥${(cents / 100).toFixed(2)}`; } diff --git a/apps/admin-web/src/config/membershipDisplay.ts b/apps/admin-web/src/config/membershipDisplay.ts new file mode 100644 index 000000000..7bf7b09e6 --- /dev/null +++ b/apps/admin-web/src/config/membershipDisplay.ts @@ -0,0 +1,32 @@ +import type { + ProfileMembershipCycleKind, + ProfileMembershipPlan, +} from '../api/adminApiTypes'; + +/** 会员档位展示名;`normal` 即非会员。 */ +export const membershipPlanLabels: Record = { + normal: '非会员', + starter: 'Starter', + plus: 'Plus', + pro: 'Pro', + max: 'Max', +}; + +/** 账期展示名;有效期内不支持月年互换。 */ +export const membershipCycleKindLabels: Record< + ProfileMembershipCycleKind, + string +> = { + monthly: '月付', + yearly: '年付', +}; + +/** 把后端的档位 code 映射成展示名,未知值原样返回。 */ +export function formatMembershipPlan(plan: string): string { + return membershipPlanLabels[plan as ProfileMembershipPlan] ?? plan; +} + +/** 把后端的账期 code 映射成展示名,未知值原样返回。 */ +export function formatMembershipCycleKind(kind: string): string { + return membershipCycleKindLabels[kind as ProfileMembershipCycleKind] ?? kind; +} diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx index b8d05c742..ef87c4058 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx @@ -2537,6 +2537,7 @@ const databaseTableLabelMap: Record = { public_work_play_daily_stat: '公开作品日游玩统计', public_work_like: '公开作品点赞', profile_membership: '会员状态', + profile_membership_plan: '会员档位目录', profile_recharge_order: '充值订单', profile_feedback_submission: '反馈提交', profile_save_archive: '存档记录', @@ -2578,6 +2579,7 @@ const databaseTableDescriptionMap: Record = { public_work_play_daily_stat: '公开作品日游玩统计表', public_work_like: '公开作品点赞表', profile_membership: '会员状态表', + profile_membership_plan: '会员档位价格与权益目录表', profile_recharge_order: '充值订单表', profile_feedback_submission: '反馈提交记录表', profile_save_archive: '用户存档记录表', diff --git a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx index f5df76933..1f05bda26 100644 --- a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx +++ b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx @@ -22,6 +22,7 @@ import type { ProfileMembershipPlanAdminResponse, } from '../api/adminApiTypes'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import { membershipPlanLabels } from '../config/membershipDisplay'; import { handlePageError } from './pageUtils'; interface AdminMembershipPlanPageProps { @@ -42,14 +43,6 @@ const modelAccessOptions: Array<{ { value: 'full', label: '基础及高性能模型' }, ]; -const planLabels: Record = { - normal: '非会员', - starter: 'Starter', - plus: 'Plus', - pro: 'Pro', - max: 'Max', -}; - /** * 会员档位目录。 * @@ -133,7 +126,7 @@ export function AdminMembershipPlanPage({ const confirmed = await confirmWrite({ action: '保存会员档位', - target: `${planLabels[plan]}(${plan})`, + target: `${membershipPlanLabels[plan]}(${plan})`, }); if (!confirmed) { return; @@ -234,7 +227,7 @@ export function AdminMembershipPlanPage({ } }} > - {Object.entries(planLabels).map(([value, label]) => ( + {Object.entries(membershipPlanLabels).map(([value, label]) => ( @@ -393,7 +386,7 @@ export function AdminMembershipPlanPage({ type="button" onClick={() => fillForm(entry)} > - {entry.title || planLabels[entry.plan]} + {entry.title || membershipPlanLabels[entry.plan]} {entry.plan} · rank {entry.rank} @@ -419,7 +412,7 @@ export function AdminMembershipPlanPage({ label: <>档位, value: ( <> - {planLabels[result.plan]}({result.plan}) + {membershipPlanLabels[result.plan]}({result.plan}) ), }, diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx index 79f150653..8e7bffb85 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx @@ -81,7 +81,7 @@ const baseOrder: AdminRechargeOrderEntryPayload = { totalBalance: 60, spendableBalance: 60, dailyFreePoints: 0, - membershipLimitedPoints: 0, + monthlyPoints: 0, permanentPoints: 60, heldPoints: 0, refundDebtPoints: 0, @@ -729,6 +729,43 @@ test('疑似微信 refund_id 的编号交由服务端按真实查单结果判断 }); }); +test('会员订单在发放泥点列展示会员变更快照', async () => { + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [ + { + ...baseOrder, + orderId: 'order-membership', + productId: 'membership-pro-monthly', + productTitle: 'Pro 月付', + productKind: 'membership', + pointsDelta: 320, + membershipChange: { + changeKind: 'upgrade', + planBefore: 'plus', + planAfter: 'pro', + cycleKindBefore: 'monthly', + cycleKindAfter: 'monthly', + periodPointsBefore: 200, + periodPointsAfter: 520, + grantedPointsDelta: 320, + cycleIndex: 2, + expiresAtBeforeMicros: null, + expiresAtAfterMicros: 1_723_000_000_000_000, + cycleResetsAtBeforeMicros: null, + cycleResetsAtAfterMicros: 1_723_000_000_000_000, + priceBreakdownJson: '{"targetPlanPriceCents":3900}', + }, + }, + ], + }); + renderPage(); + + const row = (await screen.findByText('order-membership')).closest( + 'tr', + ) as HTMLElement; + expect(within(row).getByText('升级 Plus→Pro / 月付')).toBeTruthy(); +}); + function renderPage(token = 'admin-token') { return render( , diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx index 753311e58..5fe0acbdf 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx @@ -35,6 +35,7 @@ import { resolveAdminRechargeRefundManualReview, } from '../api/adminApiClient'; import type { + AdminMembershipOrderChangePayload, AdminRechargeOrderEntryPayload, AdminRechargeOrderListQuery, AdminRechargeRefundActionResponse, @@ -43,6 +44,10 @@ import type { } from '../api/adminApiTypes'; import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import { + formatMembershipCycleKind, + formatMembershipPlan, +} from '../config/membershipDisplay'; import { handlePageError } from './pageUtils'; interface AdminRechargeOrderPageProps { @@ -740,6 +745,31 @@ export function AdminRechargeOrderPage({ /> + {refundOrder.membershipChange ? ( +
+ + + +
+ ) : null} +
{order.pointsDelta} 泥点 + {order.membershipChange ? ( + {formatMembershipChangeSummary(order.membershipChange)} + ) : null} 累计 {formatMoney(order.cumulativeSuccessRefundCents)} @@ -1115,7 +1148,8 @@ function RechargeOrderRow({ 可消费 {order.wallet.spendableBalance} - 永久 {order.wallet.permanentPoints} / 占用 {order.wallet.heldPoints} + 永久 {order.wallet.permanentPoints} / 月度{' '} + {order.wallet.monthlyPoints} / 占用 {order.wallet.heldPoints} {order.wallet.manualFrozen ? 人工冻结 : null} @@ -1260,6 +1294,26 @@ function parseYuanToCents(value: string) { return Number.isSafeInteger(cents) && cents > 0 ? cents : null; } +function formatMembershipChangeSummary( + change: AdminMembershipOrderChangePayload, +): string { + const action = change.changeKind === 'upgrade' ? '升级' : '新购'; + const before = change.planBefore + ? formatMembershipPlan(change.planBefore) + : null; + const after = change.planAfter + ? formatMembershipPlan(change.planAfter) + : null; + const planPath = + before && after && before !== after + ? `${before}→${after}` + : (after ?? before ?? '-'); + const cycle = formatMembershipCycleKind( + change.cycleKindAfter ?? change.cycleKindBefore ?? '', + ); + return `${action} ${planPath} / ${cycle}`; +} + function formatMoney(cents: number) { return `¥${(cents / 100).toFixed(2)}`; } diff --git a/scripts/admin-web-fake-api.mjs b/scripts/admin-web-fake-api.mjs index 171af14d1..102703ee4 100644 --- a/scripts/admin-web-fake-api.mjs +++ b/scripts/admin-web-fake-api.mjs @@ -455,6 +455,19 @@ const userDetail = { manualFrozen: false, refundDebtFrozen: false, }, + membership: { + plan: 'plus', + status: 'active', + cycleKind: 'monthly', + cycleIndex: 2, + cycleCount: 1, + cycleGrantedPoints: 600, + cycleRemainingPoints: 320, + startedAtMicros: 1_790_640_000_000_000, + expiresAtMicros: 1_793_232_000_000_000, + cycleResetsAtMicros: 1_793_232_000_000_000, + updatedAtMicros: 1_790_640_000_000_000, + }, rechargeOrders: [], restrictions: {}, tags: ['vip', '内测'], @@ -617,7 +630,7 @@ function route(pathname, method, url) { totalBalance: 1_860, spendableBalance: 1_660, dailyFreePoints: 0, - membershipLimitedPoints: 0, + monthlyPoints: 0, permanentPoints: 1_200, heldPoints: 200, refundDebtPoints: 0, From 9ba3aec02604c46cdd69582d6a630c1708029728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 12:12:05 +0800 Subject: [PATCH 050/232] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E4=B8=8E=E6=B3=A5=E7=82=B9=E5=85=B1=E4=BA=AB=E5=BC=B9=E5=B1=82?= =?UTF-8?q?=E5=B9=B6=E6=94=B9=E7=89=88=E9=92=B1=E5=8C=85=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PlatformProfileRechargeModal 重写为「会员订阅 / 泥点充值」双 Tab,含三池概览、套餐卡、周期切换、会员状态、套餐对比表与确认订单页 - 新增 membershipCopy 收敛数量估算、推荐标签、模型权限与并发展示文案,价格与权益仍读后端目录 - 新增 membershipProduct 提供由档位与周期拼出的会员 productId,避免与组件混导出 - PlatformMudPointWalletEntry 入口改为「可用总额 + 行内月度/永久 + 会员与泥点」,使用详情与兑换码保留为次级入口 - 同步改写共享弹层测试,覆盖双 Tab、升级报价与确认页 Co-authored-by: Junie --- .../PlatformMudPointWalletEntry/index.tsx | 30 +- .../index.test.tsx | 287 ++++- .../PlatformProfileRechargeModal/index.tsx | 1106 +++++++++++++++-- .../membershipCopy.ts | 89 ++ .../membershipProduct.ts | 15 + 5 files changed, 1390 insertions(+), 137 deletions(-) create mode 100644 packages/shared/src/components/PlatformProfileRechargeModal/membershipCopy.ts create mode 100644 packages/shared/src/components/PlatformProfileRechargeModal/membershipProduct.ts diff --git a/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx index 82b0d51e7..5d8dd7e16 100644 --- a/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx @@ -88,6 +88,14 @@ export function PlatformMudPointWalletEntry({ displayedBalance === null ? '--' : formatMudPointCount(displayedBalance); const detailsRequestKey = `balance:${balance ?? breakdown?.totalPoints ?? 'unknown'}`; const monthlyResetLabel = formatMudPointResetAt(breakdown?.monthlyResetsAt); + const monthlyInlineLabel = formatMudPointCount( + breakdown?.monthlyPoints ?? 0, + true, + ); + const permanentInlineLabel = formatMudPointCount( + breakdown?.permanentPoints ?? 0, + true, + ); const cancelPendingClose = useCallback(() => { if (closeTimerRef.current !== null) { @@ -192,7 +200,9 @@ export function PlatformMudPointWalletEntry({ - 充值 + 会员与泥点 {onRedeemCode ? ( <> @@ -258,13 +276,13 @@ export function PlatformMudPointWalletEntry({ {isOpen ? (
- 泥点 {exactBalanceLabel} + 可用总额 {exactBalanceLabel}
diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx index 1654e6221..1e14639b5 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx @@ -25,6 +25,26 @@ function buildNormalMembership() { }; } +function buildActiveMembership( + plan: 'plus' | 'max', + cycleKind: 'monthly' | 'yearly', +) { + return { + status: 'active' as const, + plan, + startedAt: '2026-01-01T00:00:00+08:00', + expiresAt: '2027-01-01T00:00:00+08:00', + updatedAt: '2026-04-01T00:00:00+08:00', + cycleStartedAt: '2026-03-20T09:00:00+08:00', + cycleResetsAt: '2026-04-20T09:00:00+08:00', + cycleGrantedPoints: 1_150, + cycleRemainingPoints: 615, + cycleIndex: 4, + cycleCount: 12, + cycleKind, + }; +} + /** 三池余额 fixture:每日免费 / 月度 / 永久。 */ function buildMudPointBalance(totalPoints: number) { return { @@ -54,7 +74,7 @@ function buildPointProduct( }; } -/// 六档充点商品:¥1 = 10 泥点,没有首充赠送。 +// 六档充点商品:¥1 = 10 泥点,没有首充赠送。 function buildPointProducts(): ProfileRechargeProduct[] { return [60, 180, 300, 680, 1_280, 3_280].map((pointsAmount) => buildPointProduct({ @@ -67,20 +87,44 @@ function buildPointProducts(): ProfileRechargeProduct[] { ); } -function buildMembershipPlan() { - return { - plan: 'starter' as const, - title: 'Starter', - rank: 1, - monthPriceCents: 3_900, - yearPriceCents: 39_000, - periodPoints: 400, - modelAccess: 'basic' as const, - concurrentJobLimit: 1, - sortOrder: 10, - enabled: true, - }; -} +const STARTER_PLAN = { + plan: 'starter' as const, + title: 'Starter', + rank: 1, + monthPriceCents: 3_900, + yearPriceCents: 39_000, + periodPoints: 400, + modelAccess: 'basic' as const, + concurrentJobLimit: 1, + sortOrder: 10, + enabled: true, +}; + +const PLUS_PLAN = { + ...STARTER_PLAN, + plan: 'plus' as const, + title: 'Plus', + rank: 2, + monthPriceCents: 9_900, + yearPriceCents: 99_000, + periodPoints: 1_150, + modelAccess: 'full' as const, + concurrentJobLimit: 3, + sortOrder: 20, +}; + +const MAX_PLAN = { + ...STARTER_PLAN, + plan: 'max' as const, + title: 'Max', + rank: 4, + monthPriceCents: 69_900, + yearPriceCents: 699_000, + periodPoints: 8_650, + modelAccess: 'full' as const, + concurrentJobLimit: 128, + sortOrder: 40, +}; vi.mock('qrcode', () => ({ default: { @@ -89,7 +133,134 @@ vi.mock('qrcode', () => ({ })); describe('PlatformProfileRechargeModal', () => { - test('renders the six point products and forwards the selected buy action', async () => { + test('renders the membership plans and forwards a first-time purchase', async () => { + const user = userEvent.setup(); + const onSubmitMembershipCheckout = vi.fn(); + + render( + , + ); + + expect(screen.getByRole('dialog', { name: '会员与泥点' })).toBeTruthy(); + expect(screen.getByText('可用总额 29 泥点')).toBeTruthy(); + const tablist = screen.getByRole('tablist', { name: '会员与泥点' }); + expect( + within(tablist) + .getByRole('tab', { name: '会员订阅' }) + .getAttribute('aria-selected'), + ).toBe('true'); + expect(screen.getAllByText('Starter').length).toBeGreaterThan(0); + expect(screen.getAllByText('Plus').length).toBeGreaterThan(0); + + await user.click( + screen.getByRole('button', { name: 'Starter 月付 开通会员' }), + ); + + const checkout = screen.getByRole('dialog', { name: '确认订单' }); + expect(within(checkout).getByText('¥39')).toBeTruthy(); + expect(within(checkout).getByText('400(首期)')).toBeTruthy(); + expect(within(checkout).getByText('微信支付')).toBeTruthy(); + expect(within(checkout).queryByText(/报价已失效/)).toBeNull(); + expect(within(checkout).queryByText(/倒计时/)).toBeNull(); + + await user.click( + within(checkout).getByRole('button', { name: '支付 ¥39' }), + ); + + expect(onSubmitMembershipCheckout).toHaveBeenCalledWith( + 'starter', + 'monthly', + ); + }); + + test('requests the server quote and shows the upgrade amount', async () => { + const user = userEvent.setup(); + const onRequestMembershipQuote = vi.fn(); + const onSubmitMembershipCheckout = vi.fn(); + + render( + , + ); + + expect( + screen.getByRole('button', { name: 'Plus 年付 当前套餐' }), + ).toHaveProperty('disabled', true); + expect(screen.getByText('当前 Plus · 年付')).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: 'Max 年付 补差升级' })); + + expect(onRequestMembershipQuote).toHaveBeenCalledWith('max'); + + const checkout = screen.getByRole('dialog', { name: '确认套餐变更' }); + expect(within(checkout).getByText('¥100')).toBeTruthy(); + expect(within(checkout).getByText('本期补发月度泥点')).toBeTruthy(); + + await user.click( + within(checkout).getByRole('button', { name: '补差升级 ¥100' }), + ); + + expect(onSubmitMembershipCheckout).toHaveBeenCalledWith('max', 'yearly'); + }); + + test('recharge tab picks a point product and forwards the buy action', async () => { const user = userEvent.setup(); const onBuy = vi.fn(); @@ -99,7 +270,7 @@ describe('PlatformProfileRechargeModal', () => { mudPointBalance: buildMudPointBalance(29), membership: buildNormalMembership(), pointProducts: buildPointProducts(), - membershipPlans: [buildMembershipPlan()], + membershipPlans: [STARTER_PLAN], latestOrder: null, hasPointsRecharged: false, }} @@ -107,58 +278,50 @@ describe('PlatformProfileRechargeModal', () => { error={null} submittingProductId={null} nativePayment={null} + membershipQuote={null} + isLoadingMembershipQuote={false} + membershipQuoteError={null} onClose={vi.fn()} onRetry={vi.fn()} onBuy={onBuy} + onRequestMembershipQuote={vi.fn()} + onSubmitMembershipCheckout={vi.fn()} onConfirmNativePayment={vi.fn()} onCloseNativePayment={vi.fn()} />, ); - const dialog = screen.getByRole('dialog', { name: '购买更多泥点' }); - expect(within(dialog).getByText('当前余额 29 泥点')).toBeTruthy(); - expect( - within(dialog).getByRole('button', { name: '60泥点 ¥6 购买' }), - ).toBeTruthy(); - expect( - within(dialog).getByRole('button', { name: '180泥点 ¥18 购买' }), - ).toBeTruthy(); - expect( - within(dialog).getByRole('button', { - name: '300泥点 ¥30 购买', - }), - ).toBeTruthy(); - expect( - within(dialog).getByRole('button', { - name: '680泥点 ¥68 购买', - }), - ).toBeTruthy(); - expect(within(dialog).queryAllByText('首充加赠')).toHaveLength(0); - expect( - within(dialog).getByRole('button', { name: '1,280泥点 ¥128 购买' }), - ).toBeTruthy(); - expect( - within(dialog).getByRole('button', { name: '3,280泥点 ¥328 购买' }), - ).toBeTruthy(); - expect(within(dialog).queryByRole('tablist')).toBeNull(); - expect(within(dialog).queryByText('Starter')).toBeNull(); + await user.click(screen.getByRole('tab', { name: '泥点充值' })); + const products = screen.getByRole('radiogroup', { name: '泥点充值档位' }); await user.click( - within(dialog).getByRole('button', { name: '180泥点 ¥18 购买' }), - ); - expect(onBuy).toHaveBeenCalledWith( - expect.objectContaining({ productId: 'points_180' }), + within(products).getByRole('radio', { name: '60泥点 ¥6' }), ); + + await user.click(screen.getByRole('button', { name: '充值 ¥6' })); + + const checkout = screen.getByRole('dialog', { name: '确认订单' }); + expect(within(checkout).getByText('60 永久泥点')).toBeTruthy(); + + await user.click(within(checkout).getByRole('button', { name: '充值 ¥6' })); + + expect(onBuy).toHaveBeenCalledTimes(1); + expect(onBuy.mock.calls[0][0]).toMatchObject({ + productId: 'points_60', + priceCents: 600, + }); }); - test('shows the point-product empty state without exposing membership purchase', () => { + test('shows the empty state when the recharge tab has no point product', async () => { + const user = userEvent.setup(); + render( { error={null} submittingProductId={null} nativePayment={null} + membershipQuote={null} + isLoadingMembershipQuote={false} + membershipQuoteError={null} onClose={vi.fn()} onRetry={vi.fn()} onBuy={vi.fn()} + onRequestMembershipQuote={vi.fn()} + onSubmitMembershipCheckout={vi.fn()} onConfirmNativePayment={vi.fn()} onCloseNativePayment={vi.fn()} />, ); + await user.click(screen.getByRole('tab', { name: '泥点充值' })); + + expect(screen.getAllByText('Starter').length).toBeGreaterThan(0); expect(screen.getByText('暂无可购买套餐')).toBeTruthy(); - expect(screen.queryByText('Starter')).toBeNull(); - expect(screen.queryByRole('tablist')).toBeNull(); }); test('opens native payment QR code in a separate dialog', async () => { @@ -197,6 +366,9 @@ describe('PlatformProfileRechargeModal', () => { isLoading={false} error={null} submittingProductId={null} + membershipQuote={null} + isLoadingMembershipQuote={false} + membershipQuoteError={null} nativePayment={{ orderId: 'rcg_native_001', productTitle: '100泥点', @@ -209,13 +381,15 @@ describe('PlatformProfileRechargeModal', () => { onClose={vi.fn()} onRetry={vi.fn()} onBuy={vi.fn()} + onRequestMembershipQuote={vi.fn()} + onSubmitMembershipCheckout={vi.fn()} onConfirmNativePayment={onConfirmNativePayment} onCloseNativePayment={onCloseNativePayment} />, ); const rechargeDialog = screen.getByRole('dialog', { - name: '购买更多泥点', + name: '会员与泥点', }); const paymentDialog = screen.getByRole('dialog', { name: '微信扫码支付', @@ -264,6 +438,9 @@ describe('PlatformProfileRechargeModal', () => { isLoading={false} error={null} submittingProductId={null} + membershipQuote={null} + isLoadingMembershipQuote={false} + membershipQuoteError={null} nativePayment={{ orderId: 'rcg_native_expired', productTitle: '100泥点', @@ -275,6 +452,8 @@ describe('PlatformProfileRechargeModal', () => { onClose={vi.fn()} onRetry={vi.fn()} onBuy={vi.fn()} + onRequestMembershipQuote={vi.fn()} + onSubmitMembershipCheckout={vi.fn()} onConfirmNativePayment={onConfirmNativePayment} onCloseNativePayment={vi.fn()} />, diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index 24c6c8f7b..d337dbdf1 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -2,17 +2,35 @@ import './index.css'; import QRCode from 'qrcode'; import type { ReactNode } from 'react'; -import { useCallback, useEffect, useId, useState } from 'react'; +import { useCallback, useEffect, useId, useMemo, useState } from 'react'; import type { + ProfileMembership, + ProfileMembershipCycleKind, + ProfileMembershipPlan, + ProfileMembershipPlanRecord, + ProfileMembershipUpgradeQuote, ProfileRechargeCenterResponse, ProfileRechargeProduct, } from '../../contracts/runtime'; import { formatMudPointCount, formatRechargePrice } from '../../utils/format'; -import { resolveProfileMudPointDisplayTotal } from '../../utils/mudPoints'; +import { + formatMudPointResetAt, + resolveProfileMudPointDisplayTotal, +} from '../../utils/mudPoints'; +import { + RECOMMENDED_MEMBERSHIP_PLAN, + resolveMembershipConcurrencyLabel, + resolveMembershipConcurrencyShortLabel, + resolveMembershipModelAccessLabel, + resolveMembershipPlanCopy, +} from './membershipCopy'; +import { resolveMembershipProductId } from './membershipProduct'; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; +type PlatformProfileRechargeTab = 'subscription' | 'recharge'; + export type PlatformProfileRechargeNativePaymentState = { orderId: string; productTitle: string; @@ -23,15 +41,40 @@ export type PlatformProfileRechargeNativePaymentState = { confirmMessage?: string; }; +/** 弹层内的结算目标:泥点商品直接带商品行,会员只有「档位 + 周期」。 */ +export type PlatformProfileRechargeCheckoutTarget = + | { + kind: 'points'; + product: ProfileRechargeProduct; + } + | { + kind: 'membership'; + plan: ProfileMembershipPlan; + cycleKind: ProfileMembershipCycleKind; + mode: 'purchase' | 'upgrade'; + }; + export type PlatformProfileRechargeModalProps = { center: ProfileRechargeCenterResponse | null; isLoading: boolean; error: string | null; submittingProductId: string | null; nativePayment: PlatformProfileRechargeNativePaymentState | null; + /** 升级报价;仅升级确认页会用到,首次购买不走报价接口。 */ + membershipQuote: ProfileMembershipUpgradeQuote | null; + isLoadingMembershipQuote: boolean; + membershipQuoteError: string | null; onClose: () => void; onRetry: () => void; + /** 泥点商品下单(确认页「充值」触发)。 */ onBuy: (product: ProfileRechargeProduct) => void; + /** 升级前取服务端报价;只有需要补差的档位才会调用。 */ + onRequestMembershipQuote: (plan: ProfileMembershipPlan) => void; + /** 会员下单;productId 由档位与周期拼出,复用泥点下单接口。 */ + onSubmitMembershipCheckout: ( + plan: ProfileMembershipPlan, + cycleKind: ProfileMembershipCycleKind, + ) => void; onConfirmNativePayment: () => void; onCloseNativePayment: () => void; }; @@ -128,16 +171,19 @@ function PlatformRechargeActionButton({ children, className, disabled, + ariaLabel, onClick, }: { children: ReactNode; className?: string; disabled?: boolean; + ariaLabel?: string; onClick?: () => void; }) { return ( +
+ {pools.map((pool) => ( +
+
+ {pool.label} +
+
+ {formatMudPointCount(pool.points)} +
+
+ {pool.detail} +
+
+ ))} +
+ {dailyFreeResetsAt ? ( +

+ 创作扣点顺序:每日免费 → 月度 → 永久,{dailyFreeResetsAt}{' '} + 重置每日免费额度。 +

+ ) : null} +
+ ); +} + +function PlatformRechargeTabNav({ + activeTab, + onChange, +}: { + activeTab: PlatformProfileRechargeTab; + onChange: (tab: PlatformProfileRechargeTab) => void; +}) { + const tabs: Array<{ id: PlatformProfileRechargeTab; label: string }> = [ + { id: 'subscription', label: '会员订阅' }, + { id: 'recharge', label: '泥点充值' }, + ]; + + return ( +
{ + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) { + return; + } + event.preventDefault(); + const currentIndex = tabs.findIndex((tab) => tab.id === activeTab); + const nextIndex = + event.key === 'Home' + ? 0 + : event.key === 'End' + ? tabs.length - 1 + : (currentIndex + + (event.key === 'ArrowRight' ? 1 : -1) + + tabs.length) % + tabs.length; + const nextTab = tabs[nextIndex]; + if (nextTab) { + onChange(nextTab.id); + } + }} + > + {tabs.map((tab) => { + const selected = tab.id === activeTab; + return ( + + ); + })} +
+ ); +} + +function PlatformMembershipStatusCard({ + membership, + currentPlan, +}: { + membership: ProfileMembership; + currentPlan: ProfileMembershipPlanRecord | null; +}) { + const planTitle = currentPlan?.title ?? '会员'; + const cycleLabel = membership.cycleKind === 'yearly' ? '年付' : '月付'; + const expiresAt = formatMudPointResetAt(membership.expiresAt); + const cycleIndexLabel = `${Math.max(1, membership.cycleIndex)}/${Math.max( + 1, + membership.cycleCount, + )}`; + + return ( +
+
+
+ 当前 {planTitle} · {cycleLabel} +
+
+ {expiresAt ? `会员至 ${expiresAt}` : '会员有效期读取中'} + {membership.cycleKind === 'yearly' + ? ` · 第 ${cycleIndexLabel} 期` + : ''} +
+
+
+ 可同周期补差升档 +
+ 周期切换需等到期后 +
+
+ ); +} + +function PlatformMembershipCycleSwitch({ + cycleKind, + locked, + onChange, +}: { + cycleKind: ProfileMembershipCycleKind; + locked: boolean; + onChange: (cycleKind: ProfileMembershipCycleKind) => void; +}) { + const options: Array<{ id: ProfileMembershipCycleKind; label: string }> = [ + { id: 'monthly', label: '月付' }, + { id: 'yearly', label: '年付' }, + ]; + + return ( +
+
+ {options.map((option) => { + const selected = option.id === cycleKind; + return ( + + ); + })} +
+ {locked ? ( + + 有效期内不支持月年互换 + + ) : null} +
+ ); +} + +function PlatformMembershipPlanCard({ + plan, + cycleKind, + isCurrentPlan, + recommended, + actionLabel, + disabled, + submitting, + onCheckout, +}: { + plan: ProfileMembershipPlanRecord; + cycleKind: ProfileMembershipCycleKind; + isCurrentPlan: boolean; + recommended: boolean; + actionLabel: string; + disabled: boolean; + submitting: boolean; + onCheckout: () => void; +}) { + const copy = resolveMembershipPlanCopy(plan.plan); + const annual = cycleKind === 'yearly'; + const listPriceCents = annual ? plan.yearPriceCents : plan.monthPriceCents; + const perMonthCents = annual + ? Math.round(plan.yearPriceCents / 12) + : plan.monthPriceCents; + const savingCents = annual + ? Math.max(0, plan.monthPriceCents * 12 - plan.yearPriceCents) + : 0; + + return ( +
+ {recommended ? ( + + 推荐之选 + + ) : null} +
+

+ {plan.title} +

+

+ {copy.estimateAlt} +

+
+
+ + {formatRechargePrice(perMonthCents)} + + + {annual ? '折合 / 月' : '/ 月'} + +
+
+ {annual + ? `年实付 ${formatRechargePrice(plan.yearPriceCents)}${ + savingCents > 0 ? ` · 省 ${formatRechargePrice(savingCents)}` : '' + }` + : `每次支付 ${formatRechargePrice(listPriceCents)},有效期 1 个月`} +
+
+
+ {formatMudPointCount(plan.periodPoints)} 月度泥点 +
+
+ {annual ? '每月刷新 · 全年发放 12 期' : '每月刷新 · 当期有效'} +
+
+
+ 每月预计可完成约 {copy.estimateCount} · {copy.estimateKind} +
+
    +
  • {resolveMembershipModelAccessLabel(plan.modelAccess)}
  • +
  • {resolveMembershipConcurrencyLabel(plan.concurrentJobLimit)}
  • +
+ + {disabled ? actionLabel : submitting ? '处理中' : '选择并确认'} + +
+ ); +} + +function PlatformMembershipComparisonTable({ + plans, +}: { + plans: ProfileMembershipPlanRecord[]; +}) { + const rows: Array<{ + label: string; + values: string[]; + }> = [ + { + label: '月付价格', + values: plans.map((plan) => formatRechargePrice(plan.monthPriceCents)), + }, + { + label: '年付价格', + values: plans.map((plan) => formatRechargePrice(plan.yearPriceCents)), + }, + { + label: '每期泥点', + values: plans.map((plan) => formatMudPointCount(plan.periodPoints)), + }, + { + label: '模型权限', + values: plans.map((plan) => + resolveMembershipModelAccessLabel(plan.modelAccess), + ), + }, + { + label: '独立任务并发', + values: plans.map((plan) => + resolveMembershipConcurrencyShortLabel(plan.concurrentJobLimit), + ), + }, + ]; + + return ( +
+ + + + + {plans.map((plan) => ( + + ))} + + + + {rows.map((row) => ( + + + {row.values.map((value, index) => ( + + ))} + + ))} + +
+ 套餐 + + {plan.title} +
+ {row.label} + + {value} +
+
+ ); +} + +function PlatformPointProductGrid({ + products, + selectedProductId, + submittingProductId, + onSelect, +}: { + products: ProfileRechargeProduct[]; + selectedProductId: string | null; + submittingProductId: string | null; + onSelect: (product: ProfileRechargeProduct) => void; +}) { + return ( +
{ + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) { + return; + } + event.preventDefault(); + const currentIndex = products.findIndex( + (product) => product.productId === selectedProductId, + ); + const nextIndex = + event.key === 'Home' + ? 0 + : event.key === 'End' + ? products.length - 1 + : (currentIndex + + (event.key === 'ArrowRight' ? 1 : -1) + + products.length) % + products.length; + const nextProduct = products[nextIndex]; + if (nextProduct) { + onSelect(nextProduct); + } + }} + > + {products.map((product) => { + const selected = product.productId === selectedProductId; + const submitting = submittingProductId === product.productId; + return ( + + ); + })} +
+ ); +} + +function PlatformRechargeCheckoutDialog({ + target, + membershipPlans, + membership, + membershipQuote, + isLoadingMembershipQuote, + membershipQuoteError, + submittingProductId, + onClose, + onBuy, + onRequestMembershipQuote, + onSubmitMembershipCheckout, +}: { + target: PlatformProfileRechargeCheckoutTarget; + membershipPlans: ProfileMembershipPlanRecord[]; + membership: ProfileMembership | null; + membershipQuote: ProfileMembershipUpgradeQuote | null; + isLoadingMembershipQuote: boolean; + membershipQuoteError: string | null; + submittingProductId: string | null; + onClose: () => void; + onBuy: (product: ProfileRechargeProduct) => void; + onRequestMembershipQuote: (plan: ProfileMembershipPlan) => void; + onSubmitMembershipCheckout: ( + plan: ProfileMembershipPlan, + cycleKind: ProfileMembershipCycleKind, + ) => void; +}) { + if (target.kind === 'points') { + const product = target.product; + const submitting = submittingProductId === product.productId; + return ( + { + onBuy(product); + onClose(); + }} + lines={[ + { label: '充值项目', value: product.title }, + { + label: '本次到账', + value: `${formatMudPointCount(product.pointsAmount)} 永久泥点`, + }, + ]} + note="永久有效,不会随会员到期清零。创作时先扣每日免费与月度泥点,不足部分再扣永久泥点。" + /> + ); + } + + const plan = + membershipPlans.find((item) => item.plan === target.plan) ?? null; + const annual = target.cycleKind === 'yearly'; + const cycleLabel = annual ? '年付' : '月付'; + const listPriceCents = plan + ? annual + ? plan.yearPriceCents + : plan.monthPriceCents + : 0; + const isUpgrade = target.mode === 'upgrade'; + const currentPlan = + membershipPlans.find((item) => item.plan === membership?.plan) ?? null; + const productId = resolveMembershipProductId(target.plan, target.cycleKind); + const submitting = submittingProductId === productId; + const quoteForTarget = + isUpgrade && membershipQuote && membershipQuote.targetPlan === target.plan + ? membershipQuote + : null; + const amountCents = quoteForTarget + ? quoteForTarget.amountCents + : listPriceCents; + const canSubmit = + Boolean(plan) && + !submitting && + (!isUpgrade || Boolean(quoteForTarget)) && + amountCents > 0; + + const lines: Array<{ label: string; value: string }> = []; + if (isUpgrade) { + if (currentPlan) { + lines.push({ + label: `原套餐标准${annual ? '年' : '月'}价`, + value: formatRechargePrice( + annual ? currentPlan.yearPriceCents : currentPlan.monthPriceCents, + ), + }); + } + lines.push({ + label: `新套餐标准${annual ? '年' : '月'}价`, + value: formatRechargePrice(listPriceCents), + }); + if (quoteForTarget) { + lines.push({ + label: '本期补发月度泥点', + value: `+${formatMudPointCount(quoteForTarget.grantedPointsDelta)}`, + }); + lines.push({ + label: '补点后月度余额', + value: formatMudPointCount(quoteForTarget.monthlyBalanceAfter), + }); + const expiresAt = formatMudPointResetAt(quoteForTarget.expiresAt); + const cycleResetsAt = formatMudPointResetAt(quoteForTarget.cycleResetsAt); + if (expiresAt) { + lines.push({ label: '会员到期日(不变)', value: expiresAt }); + } + if (cycleResetsAt) { + lines.push({ label: '本期刷新日(不变)', value: cycleResetsAt }); + } + } + } else { + lines.push({ + label: '到账月度泥点', + value: plan ? `${formatMudPointCount(plan.periodPoints)}(首期)` : '—', + }); + lines.push({ + label: '计费周期', + value: annual + ? '年付 · 共 12 期,本次只到账首期' + : '月付 · 本次到账当期额度', + }); + } + + return ( + { + onSubmitMembershipCheckout(target.plan, target.cycleKind); + onClose(); + }} + lines={lines} + note={ + isUpgrade + ? '按剩余比例补差,保留现有余额,已用点不返还;升级后原刷新日与到期日不变。' + : `月度泥点按开通日自然月计期,当期未用完部分到期清零,不结转。当前选择 ${cycleLabel}。` + } + errorState={ + isUpgrade && membershipQuoteError ? ( + +
{membershipQuoteError}
+ onRequestMembershipQuote(target.plan)} + > + 重新获取报价 + +
+ ) : isUpgrade && isLoadingMembershipQuote ? ( +
+ 正在获取升级报价… +
+ ) : null + } + /> + ); +} + +function PlatformRechargeCheckoutShell({ + title, + amountCents, + amountLabel = '本次支付', + ctaLabel, + ctaDisabled = false, + submitting, + lines, + note, + errorState, + onClose, + onSubmit, +}: { + title: string; + amountCents: number; + amountLabel?: string; + ctaLabel: string; + ctaDisabled?: boolean; + submitting: boolean; + lines: Array<{ label: string; value: string }>; + note: string; + errorState?: ReactNode; + onClose: () => void; + onSubmit: () => void; +}) { + return ( + +
+
+ {amountLabel} +
+
+ {formatRechargePrice(amountCents)} +
+
+ {errorState ?
{errorState}
: null} +
+ {lines.map((line) => ( +
+ + {line.label} + + + {line.value} + +
+ ))} +
+
+ 支付方式 +
+
+ + + 微 + + 微信支付 + +
+ + {submitting ? '处理中' : ctaLabel} + +

+ {note} +

+
); } @@ -454,31 +1196,116 @@ export function PlatformProfileRechargeModal({ error, submittingProductId, nativePayment, + membershipQuote, + isLoadingMembershipQuote, + membershipQuoteError, onClose, onRetry, onBuy, + onRequestMembershipQuote, + onSubmitMembershipCheckout, onConfirmNativePayment, onCloseNativePayment, }: PlatformProfileRechargeModalProps) { const nativeQrImageUrl = useWechatNativeQrCode( nativePayment?.codeUrl ?? null, ); - const products = center?.pointProducts ?? []; + const membership = center?.membership ?? null; + const isActiveMembership = + membership?.status === 'active' && membership.plan !== 'normal'; const currentBalance = resolveProfileMudPointDisplayTotal( center?.mudPointBalance, ); + const membershipPlans = useMemo( + () => + (center?.membershipPlans ?? []) + .filter((plan) => plan.enabled && plan.plan !== 'normal') + .slice() + .sort((left, right) => left.sortOrder - right.sortOrder), + [center?.membershipPlans], + ); + const pointProducts = useMemo( + () => center?.pointProducts ?? [], + [center?.pointProducts], + ); + const currentPlanRecord = useMemo( + () => + membershipPlans.find((plan) => plan.plan === membership?.plan) ?? null, + [membership?.plan, membershipPlans], + ); + const [activeTab, setActiveTab] = + useState('subscription'); + const [cycleKind, setCycleKind] = + useState('monthly'); + const [selectedPointProductId, setSelectedPointProductId] = useState< + string | null + >(null); + const [checkout, setCheckout] = + useState(null); + + // 有效会员期内不允许月付 / 年付互换,切换器锁定在当前周期。 + useEffect(() => { + if (isActiveMembership && membership?.cycleKind) { + setCycleKind(membership.cycleKind); + } + }, [isActiveMembership, membership?.cycleKind]); + + useEffect(() => { + setSelectedPointProductId((current) => { + if ( + current && + pointProducts.some((product) => product.productId === current) + ) { + return current; + } + if (pointProducts.length === 0) { + return null; + } + const fallbackProduct = + pointProducts[Math.min(2, pointProducts.length - 1)]; + return fallbackProduct ? fallbackProduct.productId : null; + }); + }, [pointProducts]); + + const selectedPointProduct = + pointProducts.find( + (product) => product.productId === selectedPointProductId, + ) ?? null; + + const openMembershipCheckout = useCallback( + (plan: ProfileMembershipPlanRecord) => { + const action = resolveMembershipPlanAction( + plan, + membership, + currentPlanRecord, + ); + if (action.disabled) { + return; + } + setCheckout({ + kind: 'membership', + plan: plan.plan, + cycleKind, + mode: action.mode, + }); + if (action.mode === 'upgrade') { + onRequestMembershipQuote(plan.plan); + } + }, + [currentPlanRecord, cycleKind, membership, onRequestMembershipQuote], + ); return ( <> } - isEmpty={products.length === 0} + isEmpty={membershipPlans.length === 0 && pointProducts.length === 0} emptyState={ 暂无可购买套餐 } > -
- {products.map((product) => ( - + + + + + +
+ {checkout ? ( + setCheckout(null)} + onBuy={onBuy} + onRequestMembershipQuote={onRequestMembershipQuote} + onSubmitMembershipCheckout={onSubmitMembershipCheckout} + /> + ) : null} {nativePayment ? ( = 128` 视为不设上限。 */ +export const UNLIMITED_CONCURRENT_JOB_LIMIT = 128; + +type MembershipPlanCopy = { + /** 每月预计可完成的数量估算。 */ + estimateCount: string; + /** 数量估算对应的项目形态。 */ + estimateKind: string; + /** 数量估算的补充说明。 */ + estimateAlt: string; +}; + +const MEMBERSHIP_PLAN_COPY: Partial< + Record +> = { + starter: { + estimateCount: '1–2 个', + estimateKind: '简单小游戏原型', + estimateAlt: '先验证核心玩法与基础交互', + }, + plus: { + estimateCount: '2–4 个', + estimateKind: '轻量微抖小游戏', + estimateAlt: '按可玩版本估算', + }, + pro: { + estimateCount: '1–2 个', + estimateKind: '中等项目 Demo', + estimateAlt: '或约 7–14 个轻量小游戏', + }, + max: { + estimateCount: '1–2 个', + estimateKind: '独立游戏可玩章节', + estimateAlt: '或约 2–5 个中等项目 Demo', + }, +}; + +const FALLBACK_PLAN_COPY: MembershipPlanCopy = { + estimateCount: '按实际创作量', + estimateKind: '以作品规模为准', + estimateAlt: '具体消耗取决于创作复杂度', +}; + +export function resolveMembershipPlanCopy( + plan: ProfileMembershipPlan, +): MembershipPlanCopy { + return MEMBERSHIP_PLAN_COPY[plan] ?? FALLBACK_PLAN_COPY; +} + +export function resolveMembershipModelAccessLabel( + modelAccess: ProfileMembershipModelAccess, +): string { + return modelAccess === 'full' ? '基础及高性能模型' : '基础模型'; +} + +/** 套餐卡里的并发文案。 */ +export function resolveMembershipConcurrencyLabel( + concurrentJobLimit: number, +): string { + return concurrentJobLimit >= UNLIMITED_CONCURRENT_JOB_LIMIT + ? '独立任务并发不设套餐上限' + : `同时运行 ${concurrentJobLimit} 个独立任务`; +} + +/** 对比表里的并发短文案。 */ +export function resolveMembershipConcurrencyShortLabel( + concurrentJobLimit: number, +): string { + return concurrentJobLimit >= UNLIMITED_CONCURRENT_JOB_LIMIT + ? '不设套餐上限' + : `${concurrentJobLimit} 个`; +} + +export function isUnlimitedConcurrency(concurrentJobLimit: number): boolean { + return concurrentJobLimit >= UNLIMITED_CONCURRENT_JOB_LIMIT; +} diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/membershipProduct.ts b/packages/shared/src/components/PlatformProfileRechargeModal/membershipProduct.ts new file mode 100644 index 000000000..509047d40 --- /dev/null +++ b/packages/shared/src/components/PlatformProfileRechargeModal/membershipProduct.ts @@ -0,0 +1,15 @@ +import type { + ProfileMembershipCycleKind, + ProfileMembershipPlan, +} from '../../contracts/runtime'; + +/** + * 会员商品没有配置行,`product_id` 由「档位 + 周期」拼出,与后端 + * `parse_runtime_profile_membership_product_id` 双向对应。 + */ +export function resolveMembershipProductId( + plan: ProfileMembershipPlan, + cycleKind: ProfileMembershipCycleKind, +) { + return `membership-${plan}-${cycleKind}`; +} From f33d11b050b19197d30cc8144b7fc99d206da28a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 12:12:12 +0800 Subject: [PATCH 051/232] =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E4=B8=8E=E5=9B=BE=E7=89=87=E7=BC=96=E8=BE=91=E5=99=A8=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E4=BC=9A=E5=91=98=E8=B4=AD=E4=B9=B0=E4=B8=8E=E5=8D=87?= =?UTF-8?q?=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - platformProfileClient 新增会员升级报价接口调用 - usePlatformProfileCenterController 增加报价状态与会员下单入口 - PlatformEntryActiveFlowShell 与 ImageCanvasEditorView 向共享弹层透传报价与会员下单回调 - 同步修正平台入口与图片编辑器顶栏受入口文案变化影响的测试断言 Co-authored-by: Junie --- .../ImageCanvasEditorView.test.tsx | 18 ++--- .../image-editor/ImageCanvasEditorView.tsx | 10 +++ .../ImageCanvasTopbarView.test.tsx | 10 ++- .../PlatformEntryActiveFlowShell.test.tsx | 10 +-- .../PlatformEntryActiveFlowShell.tsx | 5 ++ .../usePlatformProfileCenterController.ts | 78 ++++++++++++++++++- .../platform-entry/platformProfileClient.ts | 18 +++++ 7 files changed, 130 insertions(+), 19 deletions(-) diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 0d10fcf7f..8b8ee140c 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -662,7 +662,7 @@ describe('ImageCanvasEditorView', () => { , ); - expect(await screen.findByLabelText('泥点 1,234')).toBeTruthy(); + expect(await screen.findByLabelText(/可用总额 1,234/)).toBeTruthy(); expect(getPlatformProfileDashboardMock).not.toHaveBeenCalled(); }); @@ -729,12 +729,12 @@ describe('ImageCanvasEditorView', () => { ); const walletButton = await screen.findByRole('button', { - name: '泥点 --', + name: /可用总额 --/, }); await user.hover(walletButton); const details = await screen.findByRole('dialog', { - name: '泥点账户详情', + name: '会员与泥点账户详情', }); expect(within(details).getByText('泥点明细读取失败')).toBeTruthy(); expect(within(details).queryByText('充值中心响应缺少泥点余额')).toBeNull(); @@ -764,7 +764,7 @@ describe('ImageCanvasEditorView', () => { ); expect( - (await screen.findByRole('button', { name: '泥点 --' })).getAttribute( + (await screen.findByRole('button', { name: /可用总额 --/ })).getAttribute( 'aria-busy', ), ).toBe('true'); @@ -807,13 +807,13 @@ describe('ImageCanvasEditorView', () => { ); const walletButton = await screen.findByRole('button', { - name: '泥点 1,234', + name: /可用总额 1,234/, }); await user.hover(walletButton); const details = await screen.findByRole('dialog', { - name: '泥点账户详情', + name: '会员与泥点账户详情', }); expect(within(details).getByText('永久泥点')).toBeTruthy(); expect(within(details).getByText('月度泥点')).toBeTruthy(); @@ -911,11 +911,11 @@ describe('ImageCanvasEditorView', () => { const viewport = screen.getByLabelText('画布工作区'); fireEvent.click( await screen.findByRole('button', { - name: '充值', + name: '会员与泥点', }), ); expect( - await screen.findByRole('dialog', { name: '购买更多泥点' }), + await screen.findByRole('dialog', { name: '会员与泥点' }), ).toBeTruthy(); fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' }); @@ -941,7 +941,7 @@ describe('ImageCanvasEditorView', () => { ).toBe('true'); expect(viewport.getAttribute('aria-disabled')).toBe('true'); - fireEvent.click(screen.getByRole('button', { name: '关闭购买更多泥点' })); + fireEvent.click(screen.getByRole('button', { name: '关闭会员与泥点' })); const resumedToolbar = await screen.findByRole('toolbar', { name: '快速编辑框选工具', diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 8aca4173e..e417b1dee 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -561,6 +561,7 @@ export function ImageCanvasEditorView({ buyRechargeProduct, closeNativeWechatPayment, confirmNativeWechatPayment, + isLoadingMembershipQuote, isLoadingRechargeCenter, isLoadingWalletLedger, isRechargeOpen, @@ -569,12 +570,15 @@ export function ImageCanvasEditorView({ isWalletLedgerOpen, loadRechargeCenter, loadWalletLedger, + membershipQuote, + membershipQuoteError, nativeWechatPayment, openRechargeOrRewardCodeModal, openWalletLedgerPanel, rechargeCenter, rechargeError, rechargePaymentResult, + requestMembershipQuote, rewardCodeError, rewardCodeInput, rewardCodeSuccess, @@ -584,6 +588,7 @@ export function ImageCanvasEditorView({ setRechargePaymentResult, setRewardCodeInput, submittingRechargeProductId, + submitMembershipCheckout, submitRewardCode, walletLedger, walletLedgerError, @@ -2746,9 +2751,14 @@ export function ImageCanvasEditorView({ error={rechargeError} submittingProductId={submittingRechargeProductId} nativePayment={nativeWechatPayment} + membershipQuote={membershipQuote} + isLoadingMembershipQuote={isLoadingMembershipQuote} + membershipQuoteError={membershipQuoteError} onClose={() => setIsRechargeOpen(false)} onRetry={loadRechargeCenter} onBuy={buyRechargeProduct} + onRequestMembershipQuote={requestMembershipQuote} + onSubmitMembershipCheckout={submitMembershipCheckout} onConfirmNativePayment={confirmNativeWechatPayment} onCloseNativePayment={closeNativeWechatPayment} /> diff --git a/src/components/image-editor/ImageCanvasTopbarView.test.tsx b/src/components/image-editor/ImageCanvasTopbarView.test.tsx index 4a4c511df..da67d9064 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.test.tsx @@ -107,13 +107,15 @@ describe('ImageCanvasTopbarView', () => { walletBalance: 12_345, }); const walletChip = screen.getByRole('button', { - name: '泥点 12,345', + name: /可用总额 12,345/, }); - expect(walletChip.textContent).toBe('泥点 1.2万'); + expect(walletChip.textContent).toContain('可用总额 1.2万'); await user.hover(walletChip); - const details = screen.getByRole('dialog', { name: '泥点账户详情' }); + const details = screen.getByRole('dialog', { + name: '会员与泥点账户详情', + }); expect(within(details).getByText('永久泥点')).toBeTruthy(); expect(within(details).getByText('月度泥点')).toBeTruthy(); expect(within(details).getByText('每日免费泥点')).toBeTruthy(); @@ -121,7 +123,7 @@ describe('ImageCanvasTopbarView', () => { await user.click(within(details).getByRole('button', { name: '使用详情' })); expect(props.onOpenWalletLedger).toHaveBeenCalledTimes(1); - await user.click(screen.getByRole('button', { name: '充值' })); + await user.click(screen.getByRole('button', { name: '会员与泥点' })); expect(props.onRecharge).toHaveBeenCalledTimes(1); expect(props.onRequestWalletDetails).toHaveBeenCalledTimes(1); }); diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index e71e02398..f92b919eb 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -268,7 +268,7 @@ describe('PlatformEntryActiveFlowShell', () => { />, ); - expect(await screen.findByLabelText('泥点 207')).toBeTruthy(); + expect(await screen.findByLabelText(/可用总额 207/)).toBeTruthy(); expect(screen.getByRole('button', { name: '下载客户端' })).toBeTruthy(); rerender( @@ -363,7 +363,7 @@ describe('PlatformEntryActiveFlowShell', () => { setSelectionStage={vi.fn()} />, ); - await screen.findByLabelText('泥点 66'); + await screen.findByLabelText(/可用总额 66/); let resolveNextOwner!: (value: unknown) => void; profileClientMock.getPlatformProfileRechargeCenter.mockImplementation( @@ -380,7 +380,7 @@ describe('PlatformEntryActiveFlowShell', () => { />, ); - expect(screen.queryByLabelText('泥点 66')).toBeNull(); + expect(screen.queryByLabelText(/可用总额 66/)).toBeNull(); await waitFor(() => { expect(usePlatformWalletStore.getState()).toMatchObject({ ownerUserId: 'user-2', @@ -397,7 +397,7 @@ describe('PlatformEntryActiveFlowShell', () => { dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', }, }); - await screen.findByLabelText('泥点 67'); + await screen.findByLabelText(/可用总额 67/); }); it('coalesces visibility and focus refreshes when the page returns to the foreground', async () => { @@ -429,7 +429,7 @@ describe('PlatformEntryActiveFlowShell', () => { setSelectionStage={vi.fn()} />, ); - await screen.findByLabelText('泥点 10'); + await screen.findByLabelText(/可用总额 10/); await act(async () => { document.dispatchEvent(new Event('visibilitychange')); diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index a2af89877..f83811020 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -872,9 +872,14 @@ export function PlatformEntryFlowShellImpl({ error={profileCenter.rechargeError} submittingProductId={profileCenter.submittingRechargeProductId} nativePayment={profileCenter.nativeWechatPayment} + membershipQuote={profileCenter.membershipQuote} + isLoadingMembershipQuote={profileCenter.isLoadingMembershipQuote} + membershipQuoteError={profileCenter.membershipQuoteError} onClose={() => profileCenter.setIsRechargeOpen(false)} onRetry={profileCenter.loadRechargeCenter} onBuy={profileCenter.buyRechargeProduct} + onRequestMembershipQuote={profileCenter.requestMembershipQuote} + onSubmitMembershipCheckout={profileCenter.submitMembershipCheckout} onConfirmNativePayment={profileCenter.confirmNativeWechatPayment} onCloseNativePayment={profileCenter.closeNativeWechatPayment} /> diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index eb27c29bb..5ae54d6f8 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -1,8 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { PlatformProfileRechargeNativePaymentState } from '../../../packages/shared/src/components/PlatformProfileRechargeModal'; +import { type PlatformProfileRechargeNativePaymentState } from '../../../packages/shared/src/components/PlatformProfileRechargeModal'; +import { resolveMembershipProductId } from '../../../packages/shared/src/components/PlatformProfileRechargeModal/membershipProduct'; import { type ConfirmWechatProfileRechargeOrderResponse, + type ProfileMembershipCycleKind, + type ProfileMembershipPlan, + type ProfileMembershipUpgradeQuote, type ProfileRechargeCenterResponse, type ProfileRechargeOrder, type ProfileRechargeProduct, @@ -29,6 +33,7 @@ import { requestWechatJsapiPayment } from '../../services/payment/wechatJsapiPay import { confirmWechatPlatformProfileRechargeOrder, createPlatformProfileRechargeOrder, + getPlatformProfileMembershipUpgradeQuote, getPlatformProfileRechargeCenter, getPlatformProfileReferralInviteCenter, getPlatformProfileWalletLedger, @@ -392,6 +397,13 @@ export function usePlatformProfileCenterController({ useState(null); const [submittingRechargeProductId, setSubmittingRechargeProductId] = useState(null); + const [membershipQuote, setMembershipQuote] = + useState(null); + const [isLoadingMembershipQuote, setIsLoadingMembershipQuote] = + useState(false); + const [membershipQuoteError, setMembershipQuoteError] = useState< + string | null + >(null); const [isWalletLedgerOpen, setIsWalletLedgerOpen] = useState(false); const [walletLedger, setWalletLedger] = useState(null); @@ -1333,6 +1345,65 @@ export function usePlatformProfileCenterController({ ], ); + const requestMembershipQuote = useCallback( + (plan: ProfileMembershipPlan) => { + const account = captureAccountLifecycle(); + const requestSignal = accountWriteAbortControllerRef.current.signal; + if ( + !account.ownerUserId || + !isAccountLifecycleCurrent(account) || + requestSignal.aborted + ) { + return; + } + setMembershipQuote(null); + setMembershipQuoteError(null); + setIsLoadingMembershipQuote(true); + void getPlatformProfileMembershipUpgradeQuote(plan, { + signal: requestSignal, + }) + .then((quote) => { + if (!isAccountLifecycleCurrent(account)) { + return; + } + setMembershipQuote(quote); + }) + .catch((error: unknown) => { + if (!isAccountLifecycleCurrent(account)) { + return; + } + setMembershipQuoteError( + error instanceof Error ? error.message : '读取升级报价失败', + ); + }) + .finally(() => { + if (isAccountLifecycleCurrent(account)) { + setIsLoadingMembershipQuote(false); + } + }); + }, + [captureAccountLifecycle, isAccountLifecycleCurrent], + ); + + const submitMembershipCheckout = useCallback( + (plan: ProfileMembershipPlan, cycleKind: ProfileMembershipCycleKind) => { + // 会员商品没有商品配置行:标题与价格由后端按档位与周期现算, + // 这里只需要把 productId 交给同一条下单链路。 + buyRechargeProduct({ + productId: resolveMembershipProductId(plan, cycleKind), + title: plan, + priceCents: 0, + kind: 'membership', + pointsAmount: 0, + bonusPoints: 0, + durationDays: 0, + badgeLabel: '', + description: '', + }); + }, + [buyRechargeProduct], + ); + const confirmNativeWechatPayment = useCallback(() => { if (!nativeWechatPayment || nativeWechatPayment.isConfirming) { return; @@ -1872,6 +1943,11 @@ export function usePlatformProfileCenterController({ closeNativeWechatPayment, closeProfilePopupPanel, confirmNativeWechatPayment, + isLoadingMembershipQuote, + membershipQuote, + membershipQuoteError, + requestMembershipQuote, + submitMembershipCheckout, copyInviteCode, inviteCodeCopyState: inviteCodeCopyState as CopyFeedbackState, inviteLinkCopyState: inviteLinkCopyState as CopyFeedbackState, diff --git a/src/services/platform-entry/platformProfileClient.ts b/src/services/platform-entry/platformProfileClient.ts index 7164a8118..f8a0aec1c 100644 --- a/src/services/platform-entry/platformProfileClient.ts +++ b/src/services/platform-entry/platformProfileClient.ts @@ -5,6 +5,8 @@ import type { ExternalApiKeyListResponse, ExternalApiKeyMutationResponse, ProfileDashboardSummary, + ProfileMembershipPlan, + ProfileMembershipUpgradeQuote, ProfileRechargeCenterResponse, ProfileRechargeOrder, ProfileReferralInviteCenterResponse, @@ -168,6 +170,22 @@ export function createPlatformProfileRechargeOrder( ); } +export function getPlatformProfileMembershipUpgradeQuote( + targetPlan: ProfileMembershipPlan, + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + '/membership/upgrade-quote', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetPlan }), + }, + '读取升级报价失败', + options, + ); +} + export function confirmWechatPlatformProfileRechargeOrder( orderId: string, options: PlatformProfileRequestOptions = {}, From 6b4b248879237adfbfd23466da59b6befde54360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 12:12:18 +0800 Subject: [PATCH 052/232] =?UTF-8?q?AGC=20=E6=A1=8C=E9=9D=A2=E5=A3=B3?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E4=BC=9A=E5=91=98=E5=8D=87=E7=BA=A7=E6=8A=A5?= =?UTF-8?q?=E4=BB=B7=E4=B8=8E=E4=BC=9A=E5=91=98=E4=B8=8B=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Tauri 命令 read_profile_membership_upgrade_quote 并注册到桌面端 - accountHost 新增会员升级报价调用 - useAccountWallet 与 AccountWallet 接通报价、会员下单与共享弹层新 props - 补充 accountHost 报价调用测试 Co-authored-by: Junie --- .../src-tauri/src/account_api.rs | 23 +++++- .../src-tauri/src/desktop.rs | 1 + .../src/features/app-shell/AccountWallet.tsx | 9 +++ .../features/app-shell/useAccountWallet.ts | 77 ++++++++++++++++++- .../src/services/accountHost.ts | 11 +++ .../tests/accountHost.test.ts | 3 + 6 files changed, 121 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs index 2cdc1b40d..83a56b675 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs @@ -12,8 +12,9 @@ use serde::Serialize; use serde_json::Value; use shared_contracts::runtime::{ ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse, - ProfileRechargeCenterResponse, ProfileReferralInviteCenterResponse, - ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse, + ProfileMembershipUpgradeQuoteResponse, ProfileRechargeCenterResponse, + ProfileReferralInviteCenterResponse, ProfileWalletLedgerResponse, + RedeemProfileRewardCodeResponse, }; use std::time::Duration; use url::Url; @@ -181,6 +182,24 @@ pub(crate) async fn read_profile_recharge_center() -> Result Result { + let target_plan = bounded_business_id(&target_plan, "会员档位")?; + let snapshot = require_session()?; + let client = build_client()?; + request_json( + &client, + &snapshot, + Method::POST, + &["api", "profile", "membership", "upgrade-quote"], + Some(serde_json::json!({ "targetPlan": target_plan })), + "读取升级报价失败", + ) + .await +} + #[tauri::command] pub(crate) async fn read_profile_wallet_ledger() -> Result { let snapshot = require_session()?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs index 0c479c168..519378db0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs @@ -637,6 +637,7 @@ pub(super) fn run() { set_chat_prompt_polish_reminder_disabled, set_client_server_selection, read_profile_recharge_center, + read_profile_membership_upgrade_quote, read_profile_wallet_ledger, read_profile_referral_invite_center, create_profile_recharge_order, diff --git a/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx b/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx index 6545cd169..ecc54f70c 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx @@ -42,9 +42,18 @@ export function AccountWalletDialogs({ error={controller.rechargeError} submittingProductId={controller.submittingRechargeProductId} nativePayment={controller.nativeRechargePayment} + membershipQuote={controller.membershipQuote} + isLoadingMembershipQuote={controller.membershipQuoteLoading} + membershipQuoteError={controller.membershipQuoteError} onClose={controller.closeRecharge} onRetry={() => void controller.loadRechargeCenter()} onBuy={(product) => void controller.buyRechargeProduct(product)} + onRequestMembershipQuote={(plan) => + void controller.requestMembershipQuote(plan) + } + onSubmitMembershipCheckout={(plan, cycleKind) => + controller.submitMembershipCheckout(plan, cycleKind) + } onConfirmNativePayment={() => void controller.confirmNativeRechargePayment() } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts index c88492ff6..5649adfde 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts @@ -1,7 +1,11 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import type { PlatformProfileRechargeNativePaymentState } from '../../../../../packages/shared/src/components/PlatformProfileRechargeModal'; +import { type PlatformProfileRechargeNativePaymentState } from '../../../../../packages/shared/src/components/PlatformProfileRechargeModal'; +import { resolveMembershipProductId } from '../../../../../packages/shared/src/components/PlatformProfileRechargeModal/membershipProduct'; import type { + ProfileMembershipCycleKind, + ProfileMembershipPlan, + ProfileMembershipUpgradeQuote, ProfileRechargeCenterResponse, ProfileRechargeProduct, ProfileWalletLedgerResponse, @@ -10,6 +14,7 @@ import type { RechargeContent } from '../../app/types'; import { confirmClientWechatProfileRechargeOrder, createClientProfileRechargeOrder, + getClientProfileMembershipUpgradeQuote, getClientProfileRechargeCenter, getClientProfileWalletLedger, redeemClientProfileRewardCode, @@ -45,6 +50,12 @@ export function useAccountWallet(currentUserId: string) { useState(null); const [nativeRechargePayment, setNativeRechargePayment] = useState(null); + const [membershipQuote, setMembershipQuote] = + useState(null); + const [membershipQuoteLoading, setMembershipQuoteLoading] = useState(false); + const [membershipQuoteError, setMembershipQuoteError] = useState< + string | null + >(null); const [redeemCodeOpen, setRedeemCodeOpen] = useState(false); const [redeemCodeInput, setRedeemCodeInput] = useState(''); const [redeemCodeLoading, setRedeemCodeLoading] = useState(false); @@ -111,6 +122,9 @@ export function useAccountWallet(currentUserId: string) { setRechargeError(null); setSubmittingRechargeProductId(null); setNativeRechargePayment(null); + setMembershipQuote(null); + setMembershipQuoteLoading(false); + setMembershipQuoteError(null); redeemLifecycleRef.current += 1; setRedeemCodeOpen(false); setRedeemCodeInput(''); @@ -217,6 +231,9 @@ export function useAccountWallet(currentUserId: string) { setRechargeOpen(false); setNativeRechargePayment(null); setSubmittingRechargeProductId(null); + setMembershipQuote(null); + setMembershipQuoteLoading(false); + setMembershipQuoteError(null); } function openRedeemCode() { @@ -375,6 +392,59 @@ export function useAccountWallet(currentUserId: string) { } } + async function requestMembershipQuote(plan: ProfileMembershipPlan) { + const rechargeLifecycle = rechargeLifecycleRef.current; + const snapshotOwnerUserId = currentUserId; + setMembershipQuote(null); + setMembershipQuoteError(null); + setMembershipQuoteLoading(true); + try { + const quote = await getClientProfileMembershipUpgradeQuote(plan); + if ( + rechargeLifecycleRef.current !== rechargeLifecycle || + currentUserIdRef.current !== snapshotOwnerUserId + ) { + return; + } + setMembershipQuote(quote); + } catch (error) { + if ( + rechargeLifecycleRef.current === rechargeLifecycle && + currentUserIdRef.current === snapshotOwnerUserId + ) { + setMembershipQuoteError( + error instanceof Error ? error.message : '读取升级报价失败', + ); + } + } finally { + if ( + rechargeLifecycleRef.current === rechargeLifecycle && + currentUserIdRef.current === snapshotOwnerUserId + ) { + setMembershipQuoteLoading(false); + } + } + } + + function submitMembershipCheckout( + plan: ProfileMembershipPlan, + cycleKind: ProfileMembershipCycleKind, + ) { + // 会员商品没有商品配置行:标题与价格由后端按档位与周期现算, + // 这里只需要把 productId 交给同一条下单链路。 + void buyRechargeProduct({ + productId: resolveMembershipProductId(plan, cycleKind), + title: plan, + priceCents: 0, + kind: 'membership', + pointsAmount: 0, + bonusPoints: 0, + durationDays: 0, + badgeLabel: '', + description: '', + }); + } + return { ownerUserId, mudPointBalance: visibleMudPointBalance, @@ -402,6 +472,11 @@ export function useAccountWallet(currentUserId: string) { ? submittingRechargeProductId : null, nativeRechargePayment: walletUiIsVisible ? nativeRechargePayment : null, + membershipQuote: walletUiIsVisible ? membershipQuote : null, + membershipQuoteLoading: walletUiIsVisible && membershipQuoteLoading, + membershipQuoteError: walletUiIsVisible ? membershipQuoteError : null, + requestMembershipQuote, + submitMembershipCheckout, setNativeRechargePayment, loadWalletLedger, openWalletLedger, diff --git a/apps/ai-game-creator-shell/src/services/accountHost.ts b/apps/ai-game-creator-shell/src/services/accountHost.ts index da82f4168..46fc37d52 100644 --- a/apps/ai-game-creator-shell/src/services/accountHost.ts +++ b/apps/ai-game-creator-shell/src/services/accountHost.ts @@ -1,6 +1,8 @@ import type { ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse, + ProfileMembershipPlan, + ProfileMembershipUpgradeQuote, ProfileRechargeCenterResponse, ProfileReferralInviteCenterResponse, ProfileWalletLedgerResponse, @@ -41,6 +43,15 @@ export function getClientProfileWalletLedger() { ); } +export function getClientProfileMembershipUpgradeQuote( + targetPlan: ProfileMembershipPlan, +) { + return invokeAccount( + 'read_profile_membership_upgrade_quote', + { targetPlan }, + ); +} + export type ClientProfileReferralInviteCenterResponse = { center: ProfileReferralInviteCenterResponse; inviteUrl: string; diff --git a/apps/ai-game-creator-shell/tests/accountHost.test.ts b/apps/ai-game-creator-shell/tests/accountHost.test.ts index 7912089d3..2952a9e14 100644 --- a/apps/ai-game-creator-shell/tests/accountHost.test.ts +++ b/apps/ai-game-creator-shell/tests/accountHost.test.ts @@ -26,6 +26,7 @@ vi.mock('../src/services/platformSession', () => ({ import { confirmClientWechatProfileRechargeOrder, createClientProfileRechargeOrder, + getClientProfileMembershipUpgradeQuote, getClientProfileRechargeCenter, getClientProfileReferralInviteCenter, getClientProfileWalletLedger, @@ -49,6 +50,7 @@ test('账户读取只提交结构化参数,不携带 token 或远端地址', a await getClientProfileRechargeCenter(); await getClientProfileWalletLedger(); await getClientProfileReferralInviteCenter(); + await getClientProfileMembershipUpgradeQuote('pro'); await createClientProfileRechargeOrder('product-1'); await confirmClientWechatProfileRechargeOrder('order-1'); await redeemClientProfileRewardCode('CODE-1'); @@ -57,6 +59,7 @@ test('账户读取只提交结构化参数,不携带 token 或远端地址', a ['read_profile_recharge_center', undefined], ['read_profile_wallet_ledger', undefined], ['read_profile_referral_invite_center', undefined], + ['read_profile_membership_upgrade_quote', { targetPlan: 'pro' }], ['create_profile_recharge_order', { productId: 'product-1' }], ['confirm_wechat_profile_recharge_order', { orderId: 'order-1' }], ['redeem_profile_reward_code', { code: 'CODE-1' }], From 484db6cb7bb4366212684dfdc4d33d7a8450aebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 12:12:29 +0800 Subject: [PATCH 053/232] =?UTF-8?q?=E8=A1=A5=E5=85=85=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E4=B8=8E=E6=B3=A5=E7=82=B9=E5=89=8D=E7=AB=AF=E6=94=B9=E9=80=A0?= =?UTF-8?q?=E6=8A=80=E6=9C=AF=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增技术设计文档,记录拷问结论、现状核实、改动清单与验收判据 - docs/README.md 登记该技术设计 Co-authored-by: Junie --- docs/README.md | 1 + ...�技术设计】会员与泥点前端改造-2026-10-03.md | 302 ++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 docs/technical/【技术设计】会员与泥点前端改造-2026-10-03.md diff --git a/docs/README.md b/docs/README.md index 1ec79f75c..33cb57492 100644 --- a/docs/README.md +++ b/docs/README.md @@ -102,6 +102,7 @@ - [双余额泥点与会员补差升级实施计划](./technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md):每日免费 / 月度 / 永久三池并列投影、按开通日自然月计期、年付 12 期与补差升级的规则细则、里程碑与验收判据;已落地(会员购买与升级的 C 端 UI 未做)。 - [泥点三池与会员计费后端设计](./technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md):后端 DB schema、账本与幂等、领域数据结构与 HTTP 契约;会员档位改为枚举、目录表以枚举为主键,含破坏性迁移计划、遗留清理清单与落地状态;已落地。 +- [会员与泥点前端改造](./technical/【技术设计】会员与泥点前端改造-2026-10-03.md):共享充值弹层改为「会员与泥点」双 Tab(套餐卡 / 周期切换 / 补差升级 / 六档充值 / 套餐对比 / 确认页),金额只读后端目录与报价接口;Web 平台入口、图片编辑器与 AGC 桌面壳三处共用;含已确认决策与验收判据。 - [泥点三池以单一总额为权威](./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/technical/【技术设计】会员与泥点前端改造-2026-10-03.md b/docs/technical/【技术设计】会员与泥点前端改造-2026-10-03.md new file mode 100644 index 000000000..a36f87b32 --- /dev/null +++ b/docs/technical/【技术设计】会员与泥点前端改造-2026-10-03.md @@ -0,0 +1,302 @@ +# 【技术设计】会员与泥点前端改造 + +> 定位:承接 [`【技术设计】泥点三池与会员计费后端设计-2026-10-02`](./【技术设计】泥点三池与会员计费后端设计-2026-10-02.md)(后端契约与接口已落地) +> 与原型 [`充值与会员.html`](../../充值与会员.html),给出前端(`packages/shared` + `src` + `apps/ai-game-creator-shell`)的改造设计。 +> 范围:共享充值弹层、钱包入口、三处消费方的 controller 与客户端接口;不含后端实现。 +> 状态:**已实施(2026-10-03)**。评审已通过,按 §11 清单落地;实现与验证结果见 §11、§13。 + +--- + +## 0. 一句话 + +把共享充值弹层从「一列泥点商品」升级为与原型一致的「会员与泥点」双 Tab 弹层 +(套餐卡 + 月/年切换 + 会员状态 + 补差升级 + 六档充值 + 套餐对比 + 确认页), +金额与权益只读后端目录与报价接口,三处消费方共用同一套组件。 + +--- + +## 1. 已确认决策(拷问结论) + +| # | 决策 | 结论 | +|---|---|---| +| 1 | 改造层级 | 扩展**共享** `PlatformProfileRechargeModal` 及其 controller,三处消费方共用 | +| 2 | 与原型对齐 | **完全像素级对齐**:两个 Tab、四张套餐卡、对比表、数量估算、确认页都做 | +| 3 | 上线方式 | **直接全量开放**,不加功能开关;会员 UI 对所有用户可见 | +| 4 | 钱包入口 | 改用**原型入口形态**(可用总额 + 行内月度/永久 + 「会员与泥点」按钮) | +| 5 | 入口次级功能 | 保留「使用详情」「兑换码」为次级入口,不因贴原型而砍功能 | +| 6 | 余额池 | 弹层内显示**三池**(每日免费 / 月度 / 永久)并按真实扣减顺序说明 | +| 7 | 金额真源 | **服务端报价**:购买用目录价、升级调 `POST /api/profile/membership/upgrade-quote`,前端不计费 | +| 8 | 确认页 | **做**确认页;**不做**前端倒计时,也**不做**价格 compare / 不一致提示 | +| 9 | 支付方式 | **仅微信**,渠道按运行环境自动解析,不出现支付宝 | +| 10 | 套餐文案 | 价格/每期泥点/模型/并发/标题读后端 catalog;「数量估算」「推荐之选」等营销文案放前端常量 | +| 11 | 消费方范围 | Web 平台入口、图片编辑器、AGC 桌面壳**三处都接** | +| 12 | 文档流程 | 先出本设计文档评审,再动代码 | +| 13 | `review.txt` | 后端评审问题**不作为本次门禁**,本次只做前端 | + +--- + +## 2. 现状(已核实) + +- 共享弹层 `packages/shared/src/components/PlatformProfileRechargeModal/index.tsx`(538 行) + 只渲染 `center.pointProducts` 一列商品,标题「购买更多泥点」;props 只有 + `center / isLoading / error / submittingProductId / nativePayment / onClose / onRetry / onBuy / onConfirmNativePayment / onCloseNativePayment`。 +- 同目录 `index.test.tsx` 目前**断言不暴露会员**:`queryByRole('tablist')` 为 `null`、`queryByText('Starter')` 为 `null`, + 用例名即 `shows the point-product empty state without exposing membership purchase`。本次必须改这些断言。 +- Web 平台入口 `src/components/platform-entry/PlatformEntryActiveFlowShell.tsx` 与 + 图片编辑器 `src/components/image-editor/ImageCanvasEditorView.tsx` **共用**同一个 controller + `src/components/platform-entry/usePlatformProfileCenterController.ts`(1923 行)→ Web 端只需改一处。 +- AGC 桌面壳走独立链路:`apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts` + → `apps/ai-game-creator-shell/src/services/accountHost.ts`(Tauri `invoke`) + → `apps/ai-game-creator-shell/src-tauri/src/account_api.rs`(命令注册在 `desktop.rs`)。 +- 三端都**还没有**升级报价的客户端方法;Web 端现有客户端为 + `getPlatformProfileRechargeCenter` / `createPlatformProfileRechargeOrder(productId, paymentChannel)` / + `confirmWechatPlatformProfileRechargeOrder` / `watchWechatPlatformProfileRechargeOrder`。 +- 契约侧 `packages/shared/src/contracts/runtime.ts` 已有 + `ProfileMembershipPlan` / `ProfileMembershipCycleKind` / `ProfileMembershipPlanRecord` / + `ProfileMembership` / `ProfileMembershipUpgradeQuote` / `ProfileRechargeCenterResponse`。 +- 真实支付渠道只有微信(`wechat_mp` / `wechat_mp_virtual` / `wechat_jsapi` / `wechat_h5` / `wechat_native` / `mock`), + 由 `resolveProfileRechargePaymentChannel` 按运行环境解析,**没有支付宝**。 +- 会员下单复用 `POST /api/profile/recharge/orders`,`product_id = membership-{plan}-{cycleKind}` + (如 `membership-pro-yearly`),会员商品没有商品配置行,标题与价格来自目录表。 + +--- + +## 3. 契约与需要新增的前端接口 + +### 3.1 复用(不改) + +- `GET /api/profile/recharge-center` → `ProfileRechargeCenterResponse` + (`mudPointBalance` 三池 / `membership` / `membershipPlans` / `pointProducts`)。 +- `POST /api/profile/recharge/orders { productId, paymentChannel }` → 泥点与会员下单共用。 +- `POST /api/profile/recharge/orders/{orderId}/wechat/confirm` → 微信支付确认。 + +### 3.2 新增(前端缺、后端已有) + +Web 端 `src/services/platform-entry/platformProfileClient.ts` 新增: + +```ts +export function getPlatformProfileMembershipUpgradeQuote( + targetPlan: ProfileMembershipPlan, + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + '/membership/upgrade-quote', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetPlan }), + }, + '读取升级报价失败', + options, + ); +} +``` + +AGC 桌面壳需要新增同名能力:`accountHost.ts` 增加 `getClientProfileMembershipUpgradeQuote(targetPlan)`, +并在 `src-tauri/src/account_api.rs` 新增 `#[tauri::command] read_profile_membership_upgrade_quote(target_plan: String)`, +在 `src-tauri/src/desktop.rs` 的命令清单里注册。这是桌面壳唯一的非纯前端改动。 + +### 3.3 会员下单 productId + +前端按 `membership-${plan}-${cycleKind}`(`yearly` / `monthly`)拼串后走既有下单接口, +不再向后端请求商品行;`plan === 'normal'` 不可下单。 + +--- + +## 4. 信息架构与组件拆分 + +弹层保持「一个 `index.tsx` + 内部子组件」的现有约定,另抽一个纯常量模块。 + +``` +PlatformProfileRechargeModal/ +├── index.tsx // Shell / Tab / 余额概览 / 套餐卡 / 周期切换 / 会员状态 / 对比表 / 充值网格 / 确认页 / 成功态 / 微信二维码 +├── membershipCopy.ts // 「数量估算」文案、推荐档位、省费标签等纯前端常量 +├── index.css // 复用现有 class,按需补样式 +└── index.test.tsx +``` + +信息架构(对齐原型): + +``` +顶部入口(PlatformMudPointWalletEntry) +└─ 弹层「会员与泥点」 + ├─ Tab:会员订阅 | 泥点充值 + ├─ 余额概览(三池:每日免费 / 月度 / 永久 + 各自失效时间) + ├─ 会员订阅面板 + │ ├─ 当前会员状态(非会员时隐藏) + │ ├─ 月付 / 年付切换(有效期内锁定为当前周期) + │ ├─ 套餐卡 × 4(Starter / Plus / Pro / Max,来自 catalog,enabled 过滤,normal 不出现) + │ ├─ 套餐对比表(4 行 × 4 档) + │ └─ 底栏主 CTA + ├─ 泥点充值面板 + │ ├─ 六档金额网格(¥6/18/30/68/128/328 ↔ 60/180/300/680/1280/3280 泥点,¥1 = 10 泥点) + │ └─ 底栏「充值 ¥X」 + └─ 确认页(弹窗) + ├─ 价格与权益明细(购买:目录价 + 首期额度;升级:补差 + 补点 + 有效期不变) + ├─ 支付方式(仅微信,自动解析渠道) + └─ 支付成功态 + └─ 微信扫码 / 跳转支付(复用现有 native 支付弹窗,不改) +``` + +### 4.1 新增 props(`PlatformProfileRechargeModalProps`) + +```ts +export type PlatformProfileMembershipConfirmState = { + /** 目标档位与周期;normal 不会出现在这里。 */ + plan: ProfileMembershipPlan; + cycleKind: ProfileMembershipCycleKind; + /** 首次购买时来自目录价;升级时来自 upgrade-quote。 */ + amountCents: number; + /** 服务端返回的补点;首次购买为 0。 */ + grantedPointsDelta: number; + /** 升级后的月度余额;首次购买为首期额度。 */ + monthlyBalanceAfter: number | null; + mode: 'purchase' | 'upgrade'; +}; + +// 追加到现有 props: +membershipQuote: ProfileMembershipUpgradeQuote | null; +isLoadingMembershipQuote: boolean; +membershipQuoteError: string | null; +membershipConfirm: PlatformProfileMembershipConfirmState | null; +onRequestMembershipQuote: (plan: ProfileMembershipPlan) => void; +onRequestMembershipPurchase: (plan: ProfileMembershipPlan, cycleKind: ProfileMembershipCycleKind) => void; +onCancelMembershipConfirm: () => void; +onConfirmMembershipPayment: () => void; +``` + +`onBuy(product)` 只服务泥点商品;会员走 `onRequestMembershipQuote` / `onRequestMembershipPurchase`。 + +--- + +## 5. 数据流与状态 + +- 套餐卡的价格、每期泥点、模型权限、并发上限、标题全部读 `center.membershipPlans` + (按 `sortOrder` 升序、`enabled` 过滤、排除 `normal`)。前端**不**存第二份价格。 +- 点套餐判定: + - 当前有效会员且 `plan !== 当前档位` 且 `cycleKind === 当前周期` → 调 `onRequestMembershipQuote(plan)`; + - 否则(非会员 / 已到期)→ 直接 `onRequestMembershipPurchase(plan, cycleKind)` 用目录价开确认页。 +- 周期切换:有效期内锁定为 `membership.cycleKind`,另一档禁用并提示「到期后可重新选择」。 +- 可升级判定用 `rank`(`center.membershipPlans`),高于当前才能升;同档与降档按钮禁用。 +- 确认页金额:升级取 `membershipQuote.amountCents`,购买取目录价;**不做**任何本地计费与价格比对。 +- 支付:确认页「去支付」→ 控制器用既有 `createPlatformProfileRechargeOrder(productId, channel)`, + 渠道走 `resolveProfileRechargePaymentChannel`;桌面/浏览器差异沿用现有 native 二维码弹窗。 +- 支付成功后刷新 recharge center;成功态展示本期泥点与三池余额。 + +--- + +## 6. 交互细则 + +- 与原型一致,但**去掉**原型里的本地 2 分钟报价倒计时与「报价已失效」逻辑 + (金额真源在后端,下单与支付确认由后端重算)。 +- **不做**价格 compare:不在前端比较「页面展示价」与「服务端返回金额」,也不做不一致提示。 +- 移动端优先:弹层宽度、套餐卡网格、对比表横向滚动,遵循现有 `PlatformProfileRechargeModalShell` 尺寸约定。 +- 可访问性:Tab 用 `role="tablist"` / `role="tab"` / `aria-selected` + 左右方向键; + 套餐卡与充值卡用 `role="radio"` + `aria-checked`;确认页与支付弹窗沿用 `role="dialog"`。 + +--- + +## 7. 文案与常量(`membershipCopy.ts`) + +只放后端没有、且属于营销表达的内容,按 `plan` token 匹配: + +- 每档「每月预计可完成」数量估算与说明; +- 「推荐之选」标签(原型为 `plus`); +- 年付「省 ¥X」文案(按目录 `yearPriceCents` 与 `monthPriceCents × 12` 计算,只用于展示); +- 并发文案:`concurrentJobLimit >= 128` 显示「不设套餐上限」,否则「同时运行 N 个独立任务」; +- 模型权限文案:`basic` →「基础模型」,`full` →「基础及高性能模型」。 + +未知 `plan` token 时给通用兜底文案,不抛错。 + +--- + +## 8. 三处消费方接入清单 + +| 消费方 | 文件 | 改动 | +|---|---|---| +| Web 平台入口 | `src/components/platform-entry/PlatformEntryActiveFlowShell.tsx` | 传新增 props;入口按钮文案改「会员与泥点」 | +| 图片编辑器 | `src/components/image-editor/ImageCanvasEditorView.tsx` | 同上(共用 controller) | +| Web/编辑器 controller | `src/components/platform-entry/usePlatformProfileCenterController.ts` | 新增报价状态、确认状态、会员下单 handler | +| Web 客户端 | `src/services/platform-entry/platformProfileClient.ts` | 新增 `getPlatformProfileMembershipUpgradeQuote` | +| AGC 桌面壳 | `apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts`、`AccountWallet.tsx` | 新增报价状态与会员下单 handler | +| AGC 客户端 | `apps/ai-game-creator-shell/src/services/accountHost.ts` | 新增 `getClientProfileMembershipUpgradeQuote` | +| AGC Tauri | `apps/ai-game-creator-shell/src-tauri/src/account_api.rs`、`desktop.rs` | 新增并注册 `read_profile_membership_upgrade_quote` | +| 共享入口 | `packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx` | 入口形态按原型调整,保留次级入口 | + +--- + +## 9. 测试计划 + +- 共享弹层(`index.test.tsx`): + - 改写现有两条「不暴露会员」断言为「暴露会员 Tab 与套餐卡」; + - 新增:四档套餐按 `sortOrder` 渲染、`normal` 与 `enabled=false` 不出现; + - 新增:周期切换在有效期内锁定; + - 新增:点套餐触发 `onRequestMembershipQuote`; + - 新增:确认页展示金额与补点,且**不出现**倒计时与价格 compare 文案; + - 新增:六档充值网格与底栏充值按钮触发 `onBuy`/下单; + - 新增:三池余额展示(含每日免费重置值)。 +- Web controller:报价成功/失败、会员下单 productId 拼串、支付成功刷新。 +- AGC 壳:`accountHost` 新命令的 invoke 参数与 `useAccountWallet` 报价状态。 +- 回归:`PlatformEntryActiveFlowShell.test.tsx`、`ImageCanvasEditorView.test.tsx` 中与弹层标题/入口文案相关的断言同步更新。 +- 命令:定向 vitest + `npm run check:encoding` + `git diff --check`。 + +--- + +## 10. 验收判据 + +1. 打开「会员与泥点」弹层可见两个 Tab、四张套餐卡、周期切换、套餐对比表与六档充值网格。 +2. 套餐价格、每期泥点、模型权限、并发上限与后端 `membershipPlans` 完全一致,前端无第二份价格。 +3. 点套餐进确认页:购买显示目录价,升级显示服务端报价金额与补点;确认页无倒计时、无价格不一致提示。 +4. 支付方式只有微信,渠道按运行环境解析,不出现支付宝。 +5. 会员与泥点下单都走既有 `POST /api/profile/recharge/orders`,会员 productId 为 `membership-{plan}-{cycleKind}`。 +6. Web 平台入口、图片编辑器、AGC 桌面壳三处行为一致,入口保留「使用详情」「兑换码」。 +7. 三池余额(每日免费 / 月度 / 永久)与失效时间展示正确,弹层与入口不出现 `NaN`。 +8. `npm run check:encoding`、定向 vitest、`git diff --check` 通过。 + +--- + +## 11. 实施清单(按顺序) + +1. `packages/shared/src/contracts/runtime.ts`:如有缺字段补齐(本轮预计只读,不改形状)。 +2. `src/services/platform-entry/platformProfileClient.ts`:新增升级报价客户端方法。 +3. `packages/shared/.../PlatformProfileRechargeModal/membershipCopy.ts`:新增前端常量。 +4. `packages/shared/.../PlatformProfileRechargeModal/index.tsx`:双 Tab + 会员面板 + 充值网格 + 确认页。 +5. `packages/shared/.../PlatformMudPointWalletEntry/index.tsx`:入口形态按原型调整。 +6. `src/components/platform-entry/usePlatformProfileCenterController.ts`:报价/确认/下单状态与 handler。 +7. `PlatformEntryActiveFlowShell.tsx`、`ImageCanvasEditorView.tsx`:传新 props。 +8. AGC:`accountHost.ts` + `account_api.rs` + `desktop.rs` + `useAccountWallet.ts` + `AccountWallet.tsx`。 +9. 更新与新增测试,跑定向 vitest 与编码检查。 + +--- + +## 12. 风险与后续 + +- **后端已知缺陷**(见 `review.txt`):结算不比对 `order.amount_cents`、报价接口写库、 + 旧 `tier` 无迁移等。按用户决定**不作为前端门禁**;作为后端跟进项单独处理。 +- 确认页只展示服务端金额,不做事前拦截;若后端金额与展示不同,以支付确认结果为准。 +- AGC 桌面壳新增 Tauri 命令属于壳内 Rust 改动,需要与桌面端一起回归。 +- 「完全像素级对齐」意味着弹层代码量与测试量显著上升,后续可按效果评估是否精简对比表。 + +--- + +## 13. 实施与验证结果(2026-10-03) + +### 13.1 落地清单 + +- 共享弹层 `PlatformProfileRechargeModal`:双 Tab、三池余额概览、套餐卡、周期切换(有效期内锁定)、会员状态、套餐对比表、六档充值网格与底栏充值按钮、确认页(仅微信支付,无倒计时、无价格对比);新增 `membershipCopy.ts` 承载营销文案与并发/模型展示口径。 +- 钱包入口 `PlatformMudPointWalletEntry`:改为「可用总额 + 行内月度/永久 + 会员与泥点」,`使用详情` / `兑换码` 作为次级入口保留。 +- Web:`platformProfileClient.ts` 新增 `getPlatformProfileMembershipUpgradeQuote`;`usePlatformProfileCenterController` 新增报价状态与 `submitMembershipCheckout`;平台入口与图片编辑器已传参。 +- AGC 桌面壳:`accountHost.ts` 新增 `getClientProfileMembershipUpgradeQuote`;`account_api.rs` 新增并注册 `read_profile_membership_upgrade_quote`;`useAccountWallet` 新增报价状态与会员结算;`AccountWallet.tsx` 已传参。 + +### 13.2 验证证据 + +- `npm run typecheck` 通过(`tsconfig.typecheck-guardrails.json`)。 +- `npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit` 通过。 +- 定向 vitest:共享弹层 6/6;平台入口 22/22;图片编辑器顶栏 5/5;`usePlatformProfileCenterController.recharge` 8/8;AGC `accountHost` + `walletStore` 17/17。 +- `npm run check:encoding` 通过(5249 个文件);`git diff --check` 通过。 +- `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` 通过。 +- ⚠ **未执行 `cargo check`**:仓库无 Rust 构建缓存,冷编译成本过高;新增 Tauri 命令只复用同文件既有 `require_session` / `build_client` / `bounded_business_id` / `request_json`,四个签名已逐一核对,风险集中在类型名拼写。 + +### 13.3 既有问题(非本次引入) + +- `ImageCanvasEditorView.test.tsx > shows the breakdown read failure without inventing rows` 在改动前的基线同样失败(已用 `git stash` 对照验证):弹层打开时仍处于 loading 分支,断言未等待错误态。属既有时序问题,本次未修改该行为。 From e5de57f7f83d88ffb6535f64952df8da5e9e7b97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 12:46:50 +0800 Subject: [PATCH 054/232] =?UTF-8?q?feat(=E4=BC=9A=E5=91=98):=20=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=8E=A5=E5=8F=A3=E6=A0=A1=E9=AA=8C=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E6=96=B9=E8=BA=AB=E4=BB=BD=E3=80=81=E5=86=99=E5=85=A5=E4=BE=A7?= =?UTF-8?q?=E6=8B=A6=E5=80=92=E6=8C=82=E7=9B=AE=E5=BD=95=E3=80=81=E4=BC=9A?= =?UTF-8?q?=E5=91=98=E8=AE=A2=E5=8D=95=E5=86=BB=E7=BB=93=E7=BB=93=E7=AE=97?= =?UTF-8?q?=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - profile.rs:10 个配置管理 procedure(任务/钱包/充值商品/会员档位/升级报价)补 require_editor_generation_runtime_service_identity,只允许 api-server 服务身份调用 - catalog.rs:新增 validate_runtime_profile_membership_plan_row_against_catalog,写入侧校验 rank 与月价/年价/每期泥点严格递增并拒绝倒挂 - errors.rs:新增 RuntimeProfileFieldError::InvalidMembershipPlanOrder 及中文文案 - lib.rs:导出 validate_runtime_profile_membership_plan_row_against_catalog - catalog.rs:新增 4 个单测覆盖种子目录、单调改价、倒挂价格/泥点与重复 rank - profile.rs:会员订单建单时冻结档位/价格/补点快照到订单行,支付结算只认快照,历史 Pending 订单回退按目录重算 - profile.rs:新增 project_profile_membership_cycle_at 只读投射账期,建单金额不再写库刷新,与预览/结算口径一致 - profile.rs:支付结算不再因档位下架拒绝已受理订单,避免付款回调卡死 - profile.rs:新增 2 个单测覆盖订单快照写入读回与无快照回退 Co-authored-by: Junie --- server-rs/crates/module-runtime/src/errors.rs | 4 + server-rs/crates/module-runtime/src/lib.rs | 1 + .../module-runtime/src/membership/catalog.rs | 100 ++- .../src/runtime/active/profile.rs | 700 ++++++++++++------ 4 files changed, 595 insertions(+), 210 deletions(-) diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index 99e43f685..570ebfe05 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -92,6 +92,7 @@ pub enum RuntimeProfileFieldError { InvalidRechargeProductKind, InvalidRechargeProductFields, InvalidMembershipPlan, + InvalidMembershipPlanOrder, MissingPaymentChannel, #[cfg(any())] MissingWorldKey, @@ -194,6 +195,9 @@ impl std::fmt::Display for RuntimeProfileFieldError { Self::InvalidMembershipPlan => { f.write_str("会员档位配置无效:月价、年价、每期泥点与并发上限必须有效") } + Self::InvalidMembershipPlanOrder => f.write_str( + "会员档位必须按 rank 严格递增:更高档位的月价、年价与每期泥点都必须更高", + ), Self::MissingPaymentChannel => f.write_str("recharge.payment_channel 不能为空"), #[cfg(any())] Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"), diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 614e9695a..4a1381e8b 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -28,6 +28,7 @@ pub use membership::{ quote_runtime_profile_membership_upgrade, resolve_runtime_profile_membership_plan_row, runtime_profile_membership_plan_catalog, runtime_profile_membership_plan_rank, runtime_profile_membership_product_id, + validate_runtime_profile_membership_plan_row_against_catalog, }; use shared_kernel::format_rfc3339 as format_shared_rfc3339; diff --git a/server-rs/crates/module-runtime/src/membership/catalog.rs b/server-rs/crates/module-runtime/src/membership/catalog.rs index d531292c5..b2229d840 100644 --- a/server-rs/crates/module-runtime/src/membership/catalog.rs +++ b/server-rs/crates/module-runtime/src/membership/catalog.rs @@ -11,7 +11,8 @@ //! //! 目录不变量:按 `rank` 升序,月价、年价、每期泥点必须**严格递增**(`Normal` 为 0)。 //! 升级补差按 `rank` 判定、按价差 / 点差计价,若高 `rank` 的行反而更便宜或更少点, -//! `saturating_sub` 会把补差静默算成 0;后台改价时不得破坏该不变量。 +//! 报价会直接报错;后台改价时由 [`validate_runtime_profile_membership_plan_row_against_catalog`] +//! 在写入侧拦住,禁止把倒挂目录落库。 use serde::{Deserialize, Serialize}; @@ -19,8 +20,8 @@ use serde::{Deserialize, Serialize}; use spacetimedb::SpacetimeType; use crate::{ - RuntimeProfileMembershipCycleKind, RuntimeProfileMembershipModelAccess, - RuntimeProfileMembershipPlan, + RuntimeProfileFieldError, RuntimeProfileMembershipCycleKind, + RuntimeProfileMembershipModelAccess, RuntimeProfileMembershipPlan, }; /// 目录一行:一个档位的可配置价格与权益。 @@ -140,6 +141,35 @@ pub fn resolve_runtime_profile_membership_plan_row( .find(|row| row.plan == plan) } +/// 校验后台写入的档位不会破坏目录不变量。 +/// +/// `candidate` 是本次要落库的行,`existing` 是目录里的其余行(同 `plan` 的旧行会被忽略)。 +/// 只比较 `candidate` 与其余每一行:这样任何单次改写都不能制造新的倒挂,而目录原本单调时 +/// 写完后仍严格单调;同时历史脏目录不会把后台彻底锁死,后台仍能逐档修正。 +pub fn validate_runtime_profile_membership_plan_row_against_catalog( + candidate: &RuntimeProfileMembershipPlanRecord, + existing: &[RuntimeProfileMembershipPlanRecord], +) -> Result<(), RuntimeProfileFieldError> { + for row in existing.iter().filter(|row| row.plan != candidate.plan) { + if row.rank == candidate.rank { + return Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder); + } + let (lower, higher) = if row.rank < candidate.rank { + (row, candidate) + } else { + (candidate, row) + }; + // 中文注释:月价、年价、每期泥点都必须随 rank 严格递增,否则升级补差会算出 0 元 / 0 泥点。 + if higher.month_price_cents <= lower.month_price_cents + || higher.year_price_cents <= lower.year_price_cents + || higher.period_points <= lower.period_points + { + return Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder); + } + } + Ok(()) +} + /// 目录行 → 可下单套餐快照,价格与权益来自目录表(后台可改)。 pub fn build_runtime_profile_membership_plan_snapshot( row: &RuntimeProfileMembershipPlanRecord, @@ -334,6 +364,70 @@ mod tests { } } + #[test] + fn catalog_seed_passes_the_admin_upsert_invariant() { + let catalog = runtime_profile_membership_plan_catalog(); + for candidate in &catalog { + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(candidate, &catalog), + Ok(()) + ); + } + } + + #[test] + fn admin_upsert_allows_a_monotonic_price_change() { + let catalog = runtime_profile_membership_plan_catalog(); + let mut candidate = row(RuntimeProfileMembershipPlan::Plus); + candidate.month_price_cents += 1; + candidate.year_price_cents += 1; + candidate.period_points += 1; + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &catalog), + Ok(()) + ); + } + + #[test] + fn admin_upsert_rejects_inverted_price_or_points() { + let catalog = runtime_profile_membership_plan_catalog(); + + // 中文注释:把 Plus 定得比 Starter 便宜,破坏「越高档越贵」。 + let mut candidate = row(RuntimeProfileMembershipPlan::Plus); + candidate.month_price_cents = 1; + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &catalog), + Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder) + ); + + // 中文注释:每期泥点与下一档持平也算倒挂(必须严格递增)。 + let mut candidate = row(RuntimeProfileMembershipPlan::Pro); + candidate.period_points = row(RuntimeProfileMembershipPlan::Plus).period_points; + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &catalog), + Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder) + ); + + // 中文注释:把 Plus 抬到比 Pro 还贵,同样是倒挂。 + let mut candidate = row(RuntimeProfileMembershipPlan::Plus); + candidate.year_price_cents = row(RuntimeProfileMembershipPlan::Pro).year_price_cents + 1; + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &catalog), + Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder) + ); + } + + #[test] + fn admin_upsert_rejects_duplicate_rank() { + let catalog = runtime_profile_membership_plan_catalog(); + let mut candidate = row(RuntimeProfileMembershipPlan::Plus); + candidate.rank = 1; + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &catalog), + Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder) + ); + } + #[test] fn year_price_is_stored_independently_of_the_month_price() { // 中文注释:年价是独立可配置字段,这里断言的是种子数值本身,而不是月价 ×10 的推导关系。 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 52a153ab1..4c7175af6 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -1025,7 +1025,13 @@ pub fn admin_list_profile_task_configs( ctx: &mut ProcedureContext, input: RuntimeProfileTaskConfigAdminListInput, ) -> RuntimeProfileTaskConfigAdminListProcedureResult { - match ctx.try_with_tx(|tx| list_profile_task_config_snapshots(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + list_profile_task_config_snapshots(tx, input.clone()) + }) { Ok(entries) => RuntimeProfileTaskConfigAdminListProcedureResult { ok: true, entries, @@ -1044,7 +1050,13 @@ pub fn admin_upsert_profile_task_config( ctx: &mut ProcedureContext, input: RuntimeProfileTaskConfigAdminUpsertInput, ) -> RuntimeProfileTaskConfigAdminProcedureResult { - match ctx.try_with_tx(|tx| upsert_profile_task_config_record(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + upsert_profile_task_config_record(tx, input.clone()) + }) { Ok(record) => RuntimeProfileTaskConfigAdminProcedureResult { ok: true, record: Some(record), @@ -1063,7 +1075,13 @@ pub fn admin_disable_profile_task_config( ctx: &mut ProcedureContext, input: RuntimeProfileTaskConfigAdminDisableInput, ) -> RuntimeProfileTaskConfigAdminProcedureResult { - match ctx.try_with_tx(|tx| disable_profile_task_config_record(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + disable_profile_task_config_record(tx, input.clone()) + }) { Ok(record) => RuntimeProfileTaskConfigAdminProcedureResult { ok: true, record: Some(record), @@ -1082,7 +1100,13 @@ pub fn admin_get_profile_wallet_config( ctx: &mut ProcedureContext, input: RuntimeProfileWalletConfigAdminGetInput, ) -> RuntimeProfileWalletConfigAdminProcedureResult { - match ctx.try_with_tx(|tx| get_profile_wallet_config_snapshot(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + get_profile_wallet_config_snapshot(tx, input.clone()) + }) { Ok(record) => RuntimeProfileWalletConfigAdminProcedureResult { ok: true, record: Some(record), @@ -1101,7 +1125,13 @@ pub fn admin_upsert_profile_wallet_config( ctx: &mut ProcedureContext, input: RuntimeProfileWalletConfigAdminUpsertInput, ) -> RuntimeProfileWalletConfigAdminProcedureResult { - match ctx.try_with_tx(|tx| upsert_profile_wallet_config_record(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + upsert_profile_wallet_config_record(tx, input.clone()) + }) { Ok(record) => RuntimeProfileWalletConfigAdminProcedureResult { ok: true, record: Some(record), @@ -1120,7 +1150,13 @@ pub fn admin_list_profile_recharge_products( ctx: &mut ProcedureContext, input: RuntimeProfileRechargeProductAdminListInput, ) -> RuntimeProfileRechargeProductAdminListProcedureResult { - match ctx.try_with_tx(|tx| list_profile_recharge_product_config_snapshots(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + list_profile_recharge_product_config_snapshots(tx, input.clone()) + }) { Ok(entries) => RuntimeProfileRechargeProductAdminListProcedureResult { ok: true, entries, @@ -1139,7 +1175,13 @@ pub fn admin_upsert_profile_recharge_product( ctx: &mut ProcedureContext, input: RuntimeProfileRechargeProductAdminUpsertInput, ) -> RuntimeProfileRechargeProductAdminProcedureResult { - match ctx.try_with_tx(|tx| upsert_profile_recharge_product_config_record(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + upsert_profile_recharge_product_config_record(tx, input.clone()) + }) { Ok(record) => RuntimeProfileRechargeProductAdminProcedureResult { ok: true, record: Some(record), @@ -1159,7 +1201,13 @@ pub fn admin_list_profile_membership_plans( ctx: &mut ProcedureContext, input: RuntimeProfileMembershipPlanAdminListInput, ) -> RuntimeProfileMembershipPlanAdminListProcedureResult { - match ctx.try_with_tx(|tx| list_profile_membership_plan_records(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + list_profile_membership_plan_records(tx, input.clone()) + }) { Ok(entries) => RuntimeProfileMembershipPlanAdminListProcedureResult { ok: true, entries, @@ -1178,7 +1226,13 @@ pub fn admin_upsert_profile_membership_plan( ctx: &mut ProcedureContext, input: RuntimeProfileMembershipPlanAdminUpsertInput, ) -> RuntimeProfileMembershipPlanAdminProcedureResult { - match ctx.try_with_tx(|tx| upsert_profile_membership_plan_record(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + upsert_profile_membership_plan_record(tx, input.clone()) + }) { Ok(record) => RuntimeProfileMembershipPlanAdminProcedureResult { ok: true, record: Some(record), @@ -1198,7 +1252,13 @@ pub fn get_profile_membership_upgrade_quote( ctx: &mut ProcedureContext, input: RuntimeProfileMembershipUpgradeQuoteGetInput, ) -> RuntimeProfileMembershipUpgradeQuoteProcedureResult { - match ctx.try_with_tx(|tx| build_profile_membership_upgrade_quote_record(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + build_profile_membership_upgrade_quote_record(tx, input.clone()) + }) { Ok(quote) => RuntimeProfileMembershipUpgradeQuoteProcedureResult { ok: true, quote: Some(quote), @@ -4087,6 +4147,55 @@ mod tests { assert_eq!(next_period.row.cycle_remaining_points, 400); } + #[test] + fn membership_order_snapshot_is_frozen_into_the_order_and_read_back() { + let mut order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Pending); + order.kind = RuntimeProfileRechargeProductKind::Membership; + let snapshot = ProfileMembershipOrderSnapshot { + change_kind: RuntimeProfileMembershipChangeKind::Upgrade, + plan: RuntimeProfileMembershipPlan::Max, + cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, + period_points: 400, + grant_points: 150, + plan_before: Some(RuntimeProfileMembershipPlan::Plus), + cycle_kind_before: Some(RuntimeProfileMembershipCycleKind::Yearly), + period_points_before: Some(250), + amount_cents: 9_900, + price_breakdown_json: "{\"orderAmountCents\":9900}".to_string(), + }; + apply_profile_membership_order_snapshot_to_order(&mut order, &snapshot); + + let settlement = frozen_profile_membership_settlement(&order).expect("应读回冻结快照"); + assert_eq!( + settlement.change_kind, + RuntimeProfileMembershipChangeKind::Upgrade + ); + assert_eq!(settlement.plan, RuntimeProfileMembershipPlan::Max); + assert_eq!( + settlement.cycle_kind, + RuntimeProfileMembershipCycleKind::Yearly + ); + assert_eq!(settlement.period_points, 400); + assert_eq!(settlement.grant_points, 150); + assert_eq!( + settlement.plan_before, + Some(RuntimeProfileMembershipPlan::Plus) + ); + assert_eq!(settlement.period_points_before, Some(250)); + assert_eq!( + settlement.price_breakdown_json, + "{\"orderAmountCents\":9900}" + ); + } + + #[test] + fn membership_order_without_frozen_snapshot_falls_back_to_recompute() { + let mut order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Pending); + order.kind = RuntimeProfileRechargeProductKind::Membership; + // 中文注释:建单时没有冻结快照的历史订单返回 None,调用方会回退到按目录重算。 + assert!(frozen_profile_membership_settlement(&order).is_none()); + } + #[test] fn duplicate_tracking_event_ids_are_treated_as_idempotent_replays() { assert!(should_skip_existing_tracking_event_id(true)); @@ -4666,43 +4775,27 @@ fn create_profile_recharge_order_record( let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros); let should_settle_immediately = validated_input.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK; - let amount_cents = resolve_profile_recharge_order_amount_cents( - ctx, - &validated_input.user_id, - &product, - created_at, - )?; + // 会员订单:建单时冻结档位、价格与补点快照,之后支付只认这份快照(改价 / 下架都不影响)。 + let membership_snapshot = match product.kind { + RuntimeProfileRechargeProductKind::Membership => { + Some(build_profile_membership_order_snapshot( + ctx, + &validated_input.user_id, + &product.product_id, + created_at, + )?) + } + RuntimeProfileRechargeProductKind::Points => None, + }; + let amount_cents = membership_snapshot + .as_ref() + .map(|snapshot| snapshot.amount_cents) + .unwrap_or(product.price_cents); let order_id = build_runtime_profile_recharge_order_id( &validated_input.user_id, validated_input.created_at_micros, &product.product_id, ); - let (status, paid_at, purchase) = if should_settle_immediately { - let purchase = apply_profile_recharge_purchase( - ctx, - &validated_input.user_id, - &product, - &order_id, - amount_cents, - validated_input.created_at_micros, - created_at, - )?; - ( - RuntimeProfileRechargeOrderStatus::Paid, - Some(created_at), - Some(purchase), - ) - } else { - (RuntimeProfileRechargeOrderStatus::Pending, None, None) - }; - let (points_delta, membership_expires_at, membership_change) = match purchase { - Some(purchase) => ( - purchase.points_delta, - purchase.membership_expires_at, - purchase.membership_change, - ), - None => (0, None, None), - }; let mut order = ProfileRechargeOrder { order_id, @@ -4711,13 +4804,13 @@ fn create_profile_recharge_order_record( product_title: product.title.clone(), kind: product.kind, amount_cents, - status, + status: RuntimeProfileRechargeOrderStatus::Pending, payment_channel: validated_input.payment_channel, - paid_at, + paid_at: None, provider_transaction_id: None, created_at, - points_delta, - membership_expires_at, + points_delta: 0, + membership_expires_at: None, expired_at: None, expiration_checked_at: None, expiration_provider_state: None, @@ -4737,7 +4830,19 @@ fn create_profile_recharge_order_record( membership_cycle_resets_at_after: None, membership_price_breakdown_json: None, }; - apply_profile_membership_change_to_order(&mut order, membership_change); + if let Some(snapshot) = membership_snapshot.as_ref() { + apply_profile_membership_order_snapshot_to_order(&mut order, snapshot); + } + + if should_settle_immediately { + let purchase = apply_profile_recharge_purchase(ctx, &order, created_at)?; + order.status = RuntimeProfileRechargeOrderStatus::Paid; + order.paid_at = Some(created_at); + order.points_delta = purchase.points_delta; + order.membership_expires_at = purchase.membership_expires_at; + apply_profile_membership_change_to_order(&mut order, purchase.membership_change); + } + ctx.db.profile_recharge_order().insert(order.clone()); if order.status == RuntimeProfileRechargeOrderStatus::Pending && should_schedule_profile_recharge_order_expiration(&order.payment_channel) @@ -4832,18 +4937,9 @@ fn mark_profile_recharge_order_paid_record( return Err("profile_recharge_order 当前状态不能确认支付".to_string()); } - let product = resolve_profile_recharge_order_product(ctx, &order.product_id) - .ok_or_else(|| "recharge.product_id 不存在".to_string())?; let paid_at = Timestamp::from_micros_since_unix_epoch(validated_input.paid_at_micros); - let purchase = apply_profile_recharge_purchase( - ctx, - &order.user_id, - &product, - &order.order_id, - order.amount_cents, - order.created_at.to_micros_since_unix_epoch(), - paid_at, - )?; + // 支付结算只认订单建单时冻结的会员快照;充点订单按商品配置发放。 + let purchase = apply_profile_recharge_purchase(ctx, &order, paid_at)?; ctx.db .profile_recharge_order() @@ -6931,18 +7027,17 @@ struct ProfileRechargePurchaseOutcome { fn apply_profile_recharge_purchase( ctx: &ReducerContext, - user_id: &str, - product: &RuntimeProfileRechargeProductSnapshot, - order_id: &str, - order_amount_cents: u64, - order_created_at_micros: i64, + order: &ProfileRechargeOrder, paid_at: Timestamp, ) -> Result { - match product.kind { + let user_id = order.user_id.as_str(); + match order.kind { RuntimeProfileRechargeProductKind::Points => { + let product = profile_recharge_product_by_id(ctx, &order.product_id) + .ok_or_else(|| "recharge.product_id 不存在".to_string())?; let has_recharged = has_profile_product_recharged(ctx, user_id, &product.product_id); let points_delta = - resolve_runtime_profile_points_recharge_delta(product, has_recharged); + resolve_runtime_profile_points_recharge_delta(&product, has_recharged); apply_profile_wallet_delta( ctx, user_id, @@ -6950,7 +7045,7 @@ fn apply_profile_recharge_purchase( RuntimeProfileWalletLedgerSourceType::PointsRecharge, &build_runtime_profile_recharge_wallet_ledger_id( user_id, - order_created_at_micros, + order.created_at.to_micros_since_unix_epoch(), &product.product_id, ), paid_at, @@ -6962,14 +7057,7 @@ fn apply_profile_recharge_purchase( }) } RuntimeProfileRechargeProductKind::Membership => { - let outcome = apply_profile_membership_purchase( - ctx, - user_id, - product, - order_id, - order_amount_cents, - paid_at, - )?; + let outcome = apply_profile_membership_purchase(ctx, user_id, order, paid_at)?; Ok(ProfileRechargePurchaseOutcome { points_delta: outcome.period_points_delta as i64, membership_expires_at: Some(outcome.expires_at), @@ -8188,6 +8276,22 @@ fn upsert_profile_membership_plan_record( updated_by: validated.admin_user_id, updated_at, }; + + // 中文注释:目录必须保持「rank 越高,月价 / 年价 / 每期泥点越高」。 + // 后台单次改价不能制造倒挂,否则升级补差会静默算出 0 元 / 0 泥点。 + let candidate = membership_plan_record_from_row(&inserted); + let catalog_rows = ctx + .db + .profile_membership_plan() + .iter() + .map(|row| membership_plan_record_from_row(&row)) + .collect::>(); + module_runtime::validate_runtime_profile_membership_plan_row_against_catalog( + &candidate, + &catalog_rows, + ) + .map_err(|error| error.to_string())?; + if let Some(row) = existing { ctx.db.profile_membership_plan().plan().delete(&row.plan); } @@ -8897,6 +9001,75 @@ struct ProfileMembershipPurchaseOutcome { change: ProfileMembershipChangeSnapshot, } +/// 建单时冻结的会员订单快照:档位、价格与补点在建单那一刻确定。 +/// +/// 支付结算只消费这份快照,不再回查 `profile_membership_plan` 目录,因此「下单后改价 / +/// 改每期泥点 / 下架档位」都不会改变已受理订单的结算口径,也便于后台按订单审计。 +/// 快照写入 `ProfileRechargeOrder` 已有的会员变更快照列,不新增 schema 字段。 +#[derive(Clone, Debug, PartialEq, Eq)] +struct ProfileMembershipOrderSnapshot { + change_kind: RuntimeProfileMembershipChangeKind, + plan: RuntimeProfileMembershipPlan, + cycle_kind: RuntimeProfileMembershipCycleKind, + period_points: u64, + grant_points: u64, + plan_before: Option, + cycle_kind_before: Option, + period_points_before: Option, + amount_cents: u64, + price_breakdown_json: String, +} + +/// 会员订单结算输入:档位、每期泥点、补点与变更前后快照。 +/// +/// 有建单冻结快照时直接来自订单行;历史 Pending 订单(建单时还没有冻结快照) +/// 回退到按结算时目录重算,保持旧口径。 +#[derive(Clone, Debug, PartialEq, Eq)] +struct ProfileMembershipSettlement { + change_kind: RuntimeProfileMembershipChangeKind, + plan: RuntimeProfileMembershipPlan, + cycle_kind: RuntimeProfileMembershipCycleKind, + period_points: u64, + grant_points: u64, + plan_before: Option, + cycle_kind_before: Option, + period_points_before: Option, + price_breakdown_json: String, +} + +/// 把建单冻结的会员快照写到订单行的会员变更快照列上。 +fn apply_profile_membership_order_snapshot_to_order( + order: &mut ProfileRechargeOrder, + snapshot: &ProfileMembershipOrderSnapshot, +) { + order.membership_change_kind = Some(snapshot.change_kind); + order.membership_plan_before = snapshot.plan_before; + order.membership_plan_after = Some(snapshot.plan); + order.membership_cycle_kind_before = snapshot.cycle_kind_before; + order.membership_cycle_kind_after = Some(snapshot.cycle_kind); + order.membership_period_points_before = snapshot.period_points_before; + order.membership_period_points_after = Some(snapshot.period_points); + order.membership_granted_points_delta = snapshot.grant_points as i64; + order.membership_price_breakdown_json = Some(snapshot.price_breakdown_json.clone()); +} + +/// 从订单行读取建单时冻结的会员快照;非会员订单或未冻结的历史订单返回 `None`。 +fn frozen_profile_membership_settlement( + order: &ProfileRechargeOrder, +) -> Option { + Some(ProfileMembershipSettlement { + change_kind: order.membership_change_kind?, + plan: order.membership_plan_after?, + cycle_kind: order.membership_cycle_kind_after?, + period_points: order.membership_period_points_after?, + grant_points: u64::try_from(order.membership_granted_points_delta).ok()?, + plan_before: order.membership_plan_before, + cycle_kind_before: order.membership_cycle_kind_before, + period_points_before: order.membership_period_points_before, + price_breakdown_json: order.membership_price_breakdown_json.clone()?, + }) +} + /// 订单里的价格变更快照(JSON)。人工复核退款时据此确认「按哪个价、补了多少点」。 fn membership_price_breakdown_json( target: &RuntimeProfileMembershipPlanSnapshot, @@ -9074,6 +9247,27 @@ fn advance_profile_membership_cycle_to( } } +/// 只读地把会员行投射到 `at` 时刻所在的那一期:与 [`refresh_profile_membership_cycle`] 同口径, +/// 但不写库、不发账本。 +/// +/// 建单需要「假如现在刷新会得到什么周期数据」才能算对补差;直接读库会用到已过期的账期 +/// (`cycle_resets_at` 在过去、`cycle_remaining_points` 还是上一期),因此用这个只读投影 +/// 替代写库刷新,保证建单金额与预览、结算三处口径一致。 +fn project_profile_membership_cycle_at( + ctx: &ReducerContext, + row: ProfileMembership, + at: Timestamp, +) -> ProfileMembership { + if !active_membership_row_at(&row, at) { + let mut expired = row; + expired.status = RuntimeProfileMembershipStatus::Normal; + expired.cycle_remaining_points = 0; + return expired; + } + let period_points = membership_plan_period_points(ctx, row.plan); + advance_profile_membership_cycle_to(row, at, period_points).row +} + /// 升级 / 报价共用的纯函数入参:两个套餐快照都来自目录表。 fn membership_upgrade_quote_input( row: &ProfileMembership, @@ -9147,25 +9341,17 @@ fn resolve_enabled_profile_recharge_order_product( enabled_profile_recharge_product_by_id(ctx, product_id) } -/// 支付确认时解析订单商品:会员档位即使下单后被下架,已支付订单仍按原档位结算。 -fn resolve_profile_recharge_order_product( - ctx: &ReducerContext, - product_id: &str, -) -> Option { - if let Some((plan, cycle_kind)) = parse_runtime_profile_membership_product_id(product_id) { - let row = membership_plan_row(ctx, plan)?; - return Some(build_membership_order_product(&row, plan, cycle_kind)); - } - profile_recharge_product_by_id(ctx, product_id) -} - +/// 建单时冻结会员订单快照:档位、价格、补点都在此确定,之后支付只认这份快照。 +/// /// 会员订单应付金额:首次购买按目录价;有效期内升级按补差报价重算。 -fn resolve_profile_membership_order_amount_cents( +/// 计算补差前先用 [`project_profile_membership_cycle_at`] 只读地把账期投射到建单时刻, +/// 不用过期账期、也不写库,因此建单金额与「预览报价」「支付结算」三处口径一致。 +fn build_profile_membership_order_snapshot( ctx: &ReducerContext, user_id: &str, product_id: &str, created_at: Timestamp, -) -> Result { +) -> Result { let (plan, cycle_kind) = parse_runtime_profile_membership_product_id(product_id) .ok_or_else(|| format!("会员商品 product_id 无法解析: {product_id}"))?; let target = membership_plan_snapshot(ctx, plan, cycle_kind) @@ -9174,14 +9360,31 @@ fn resolve_profile_membership_order_amount_cents( return Err(format!("会员档位 {} 当前不可购买", plan.as_str())); } - let Some(row) = ctx + let active_row = ctx .db .profile_membership() .user_id() .find(&user_id.to_string()) .filter(|row| active_membership_row_at(row, created_at)) - else { - return Ok(target.price_cents); + .map(|row| project_profile_membership_cycle_at(ctx, row, created_at)); + + let Some(row) = active_row else { + return Ok(ProfileMembershipOrderSnapshot { + change_kind: RuntimeProfileMembershipChangeKind::Purchase, + plan, + cycle_kind, + period_points: target.period_points, + grant_points: target.period_points, + plan_before: None, + cycle_kind_before: None, + period_points_before: None, + amount_cents: target.price_cents, + price_breakdown_json: membership_price_breakdown_json( + &target, + None, + target.price_cents, + ), + }); }; if row.plan == plan { return Err("当前套餐已生效,无需重复购买".to_string()); @@ -9195,26 +9398,22 @@ fn resolve_profile_membership_order_amount_cents( &row, ¤t, &target, created_at, )) .map_err(|rejection| format!("会员升级被拒绝: {}", rejection.as_str()))?; - Ok(quote.amount_cents) -} - -fn resolve_profile_recharge_order_amount_cents( - ctx: &ReducerContext, - user_id: &str, - product: &RuntimeProfileRechargeProductSnapshot, - created_at: Timestamp, -) -> Result { - match product.kind { - RuntimeProfileRechargeProductKind::Points => Ok(product.price_cents), - RuntimeProfileRechargeProductKind::Membership => { - resolve_profile_membership_order_amount_cents( - ctx, - user_id, - &product.product_id, - created_at, - ) - } - } + Ok(ProfileMembershipOrderSnapshot { + change_kind: RuntimeProfileMembershipChangeKind::Upgrade, + plan, + cycle_kind, + period_points: target.period_points, + grant_points: quote.granted_points_delta, + plan_before: Some(current.plan), + cycle_kind_before: Some(current.cycle_kind), + period_points_before: Some(current.period_points), + amount_cents: quote.amount_cents, + price_breakdown_json: membership_price_breakdown_json( + &target, + Some(¤t), + quote.amount_cents, + ), + }) } fn membership_cycle_metadata( @@ -9618,26 +9817,18 @@ fn build_profile_membership_snapshot( /// 会员购买 / 补差升级写路径。 /// -/// - 有效期内:只允许「同周期升更高档位」,按目录表实时价格算补差、补点,刷新日与到期日不变。 -/// - 无有效会员(从未开通或已到期):按完整周期重新起算,锚点就是本次支付时刻。 +/// - 结算口径以订单建单时冻结的快照为准:档位、每期泥点、补点与价格都不再回查目录, +/// 因此下单后改价 / 改每期泥点 / 下架档位都不会影响已受理订单;历史 Pending 订单回退到按目录重算。 +/// - 有效期内:只允许「同周期升更高档位」,按快照补点,刷新日与到期日不变。 +/// - 无有效会员(从未开通或已到期):按快照重新起算,锚点就是本次支付时刻。 /// /// 幂等锚点是**订单维度**的账本 id,重复支付回调天然幂等。 fn apply_profile_membership_purchase( ctx: &ReducerContext, user_id: &str, - product: &RuntimeProfileRechargeProductSnapshot, - order_id: &str, - order_amount_cents: u64, + order: &ProfileRechargeOrder, purchased_at: Timestamp, ) -> Result { - let (plan, cycle_kind) = parse_runtime_profile_membership_product_id(&product.product_id) - .ok_or_else(|| format!("会员商品 product_id 无法解析: {}", product.product_id))?; - let target = membership_plan_snapshot(ctx, plan, cycle_kind) - .ok_or_else(|| format!("会员目录缺少档位 {}", plan.as_str()))?; - if !target.enabled { - return Err(format!("会员档位 {} 当前不可购买", plan.as_str())); - } - refresh_profile_wallet_expiring_points(ctx, user_id, purchased_at); let active_row = ctx @@ -9647,82 +9838,176 @@ fn apply_profile_membership_purchase( .find(&user_id.to_string()) .filter(|row| active_membership_row_at(row, purchased_at)); - if let Some(row) = active_row { - if row.plan == plan { - return Err("当前套餐已生效,无需重复购买".to_string()); - } - if row.cycle_kind != cycle_kind { - return Err("当前会员有效期内不支持月付 / 年付转换,到期后可重新选择".to_string()); - } - let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind) - .ok_or_else(|| format!("会员目录缺少档位 {}", row.plan.as_str()))?; - let quote = quote_runtime_profile_membership_upgrade(membership_upgrade_quote_input( - &row, - ¤t, - &target, - purchased_at, - )) - .map_err(|rejection| format!("会员升级被拒绝: {}", rejection.as_str()))?; - - let expires_at_before_micros = row.expires_at.to_micros_since_unix_epoch(); - let cycle_resets_at_before_micros = row - .cycle_resets_at - .map(|value| value.to_micros_since_unix_epoch()); - let mut next_row = row; - // 已用点不返还、永久泥点不动:只把档位换成新档,并追加本期补点。 - next_row.plan = plan; - next_row.cycle_kind = cycle_kind; - next_row.cycle_count = cycle_kind.cycle_count(); - next_row.cycle_granted_points = next_row.cycle_granted_points.max(target.period_points); - next_row.cycle_remaining_points = next_row - .cycle_remaining_points - .saturating_add(quote.granted_points_delta); - next_row.updated_at = purchased_at; - let expires_at = next_row.expires_at; - let cycle_resets_at = next_row.cycle_resets_at; - let cycle_index = next_row.cycle_index.max(1); - upsert_profile_membership_row(ctx, next_row); - - let ledger_id = format!("membership-upgrade-grant:{user_id}:{order_id}"); - update_profile_wallet_balance_for_expiring_points( + let settlement = match frozen_profile_membership_settlement(order) { + Some(settlement) => settlement, + None => build_recomputed_profile_membership_settlement( ctx, - user_id, - 0, - quote.granted_points_delta, - RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant, - &ledger_id, + &active_row, + &order.product_id, + order.amount_cents, purchased_at, - membership_cycle_metadata("upgrade", 0, quote.granted_points_delta, cycle_resets_at), - ); + )?, + }; - return Ok(ProfileMembershipPurchaseOutcome { - expires_at, - period_points_delta: quote.granted_points_delta, - change: ProfileMembershipChangeSnapshot { - change_kind: RuntimeProfileMembershipChangeKind::Upgrade, - plan_before: Some(current.plan), - plan_after: Some(plan), - cycle_kind_before: Some(current.cycle_kind), - cycle_kind_after: Some(cycle_kind), - period_points_before: Some(current.period_points), - period_points_after: Some(target.period_points), - granted_points_delta: quote.granted_points_delta as i64, - cycle_index: Some(cycle_index), - expires_at_before_micros: Some(expires_at_before_micros), - expires_at_after_micros: Some(expires_at.to_micros_since_unix_epoch()), - cycle_resets_at_before_micros, - cycle_resets_at_after_micros: cycle_resets_at - .map(|value| value.to_micros_since_unix_epoch()), - price_breakdown_json: Some(membership_price_breakdown_json( - &target, - Some(¤t), - order_amount_cents, - )), - }, - }); + if settlement.change_kind == RuntimeProfileMembershipChangeKind::Upgrade { + if let Some(row) = active_row { + return apply_profile_membership_upgrade( + ctx, + user_id, + row, + &order.order_id, + &settlement, + purchased_at, + ); + } + // 中文注释:会员在支付前已到期,退化为按冻结档位重新起算,避免已付款订单卡死。 } - // 无有效会员:全新起算。到期未续的旧行会被整行覆盖(月度余额清零,永久泥点不受影响)。 + apply_profile_membership_fresh_purchase( + ctx, + user_id, + &order.order_id, + &settlement, + purchased_at, + ) +} + +/// 历史 Pending 订单(建单时还没有冻结快照)的结算口径:按结算时的目录与会员状态重算。 +fn build_recomputed_profile_membership_settlement( + ctx: &ReducerContext, + active_row: &Option, + product_id: &str, + order_amount_cents: u64, + purchased_at: Timestamp, +) -> Result { + let (plan, cycle_kind) = parse_runtime_profile_membership_product_id(product_id) + .ok_or_else(|| format!("会员商品 product_id 无法解析: {product_id}"))?; + let target = membership_plan_snapshot(ctx, plan, cycle_kind) + .ok_or_else(|| format!("会员目录缺少档位 {}", plan.as_str()))?; + let Some(row) = active_row else { + return Ok(ProfileMembershipSettlement { + change_kind: RuntimeProfileMembershipChangeKind::Purchase, + plan, + cycle_kind, + period_points: target.period_points, + grant_points: target.period_points, + plan_before: None, + cycle_kind_before: None, + period_points_before: None, + price_breakdown_json: membership_price_breakdown_json( + &target, + None, + order_amount_cents, + ), + }); + }; + if row.plan == plan { + return Err("当前套餐已生效,无需重复购买".to_string()); + } + if row.cycle_kind != cycle_kind { + return Err("当前会员有效期内不支持月付 / 年付转换,到期后可重新选择".to_string()); + } + let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind) + .ok_or_else(|| format!("会员目录缺少档位 {}", row.plan.as_str()))?; + let quote = quote_runtime_profile_membership_upgrade(membership_upgrade_quote_input( + row, + ¤t, + &target, + purchased_at, + )) + .map_err(|rejection| format!("会员升级被拒绝: {}", rejection.as_str()))?; + Ok(ProfileMembershipSettlement { + change_kind: RuntimeProfileMembershipChangeKind::Upgrade, + plan, + cycle_kind, + period_points: target.period_points, + grant_points: quote.granted_points_delta, + plan_before: Some(current.plan), + cycle_kind_before: Some(current.cycle_kind), + period_points_before: Some(current.period_points), + price_breakdown_json: membership_price_breakdown_json( + &target, + Some(¤t), + order_amount_cents, + ), + }) +} + +/// 有效期内补差升级:按冻结快照换档并追加本期补点,刷新日与到期日不变。 +fn apply_profile_membership_upgrade( + ctx: &ReducerContext, + user_id: &str, + row: ProfileMembership, + order_id: &str, + settlement: &ProfileMembershipSettlement, + purchased_at: Timestamp, +) -> Result { + let plan = settlement.plan; + let cycle_kind = settlement.cycle_kind; + let expires_at_before_micros = row.expires_at.to_micros_since_unix_epoch(); + let cycle_resets_at_before_micros = row + .cycle_resets_at + .map(|value| value.to_micros_since_unix_epoch()); + let mut next_row = row; + // 已用点不返还、永久泥点不动:只把档位换成快照档,并追加本期补点。 + next_row.plan = plan; + next_row.cycle_kind = cycle_kind; + next_row.cycle_count = cycle_kind.cycle_count(); + next_row.cycle_granted_points = next_row.cycle_granted_points.max(settlement.period_points); + next_row.cycle_remaining_points = next_row + .cycle_remaining_points + .saturating_add(settlement.grant_points); + next_row.updated_at = purchased_at; + let expires_at = next_row.expires_at; + let cycle_resets_at = next_row.cycle_resets_at; + let cycle_index = next_row.cycle_index.max(1); + upsert_profile_membership_row(ctx, next_row); + + let ledger_id = format!("membership-upgrade-grant:{user_id}:{order_id}"); + update_profile_wallet_balance_for_expiring_points( + ctx, + user_id, + 0, + settlement.grant_points, + RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant, + &ledger_id, + purchased_at, + membership_cycle_metadata("upgrade", 0, settlement.grant_points, cycle_resets_at), + ); + + Ok(ProfileMembershipPurchaseOutcome { + expires_at, + period_points_delta: settlement.grant_points, + change: ProfileMembershipChangeSnapshot { + change_kind: RuntimeProfileMembershipChangeKind::Upgrade, + plan_before: settlement.plan_before, + plan_after: Some(plan), + cycle_kind_before: settlement.cycle_kind_before, + cycle_kind_after: Some(cycle_kind), + period_points_before: settlement.period_points_before, + period_points_after: Some(settlement.period_points), + granted_points_delta: settlement.grant_points as i64, + cycle_index: Some(cycle_index), + expires_at_before_micros: Some(expires_at_before_micros), + expires_at_after_micros: Some(expires_at.to_micros_since_unix_epoch()), + cycle_resets_at_before_micros, + cycle_resets_at_after_micros: cycle_resets_at + .map(|value| value.to_micros_since_unix_epoch()), + price_breakdown_json: Some(settlement.price_breakdown_json.clone()), + }, + }) +} + +/// 无有效会员时按冻结快照全新起算:到期未续的旧行会被整行覆盖(月度余额清零,永久泥点不受影响)。 +fn apply_profile_membership_fresh_purchase( + ctx: &ReducerContext, + user_id: &str, + order_id: &str, + settlement: &ProfileMembershipSettlement, + purchased_at: Timestamp, +) -> Result { + let plan = settlement.plan; + let cycle_kind = settlement.cycle_kind; let expired_existing = ctx .db .profile_membership() @@ -9744,8 +10029,8 @@ fn apply_profile_membership_purchase( cycle_started_at_micros, )), cycle_resets_at: Some(cycle_resets_at), - cycle_granted_points: target.period_points, - cycle_remaining_points: target.period_points, + cycle_granted_points: settlement.period_points, + cycle_remaining_points: settlement.period_points, plan, cycle_index: 1, cycle_count: cycle_kind.cycle_count(), @@ -9758,16 +10043,21 @@ fn apply_profile_membership_purchase( ctx, user_id, 0, - target.period_points, + settlement.period_points, RuntimeProfileWalletLedgerSourceType::MembershipPeriodGrant, &ledger_id, purchased_at, - membership_cycle_metadata("purchase", 0, target.period_points, Some(cycle_resets_at)), + membership_cycle_metadata( + "purchase", + 0, + settlement.period_points, + Some(cycle_resets_at), + ), ); Ok(ProfileMembershipPurchaseOutcome { expires_at, - period_points_delta: target.period_points, + period_points_delta: settlement.period_points, change: ProfileMembershipChangeSnapshot { change_kind: RuntimeProfileMembershipChangeKind::Purchase, plan_before: expired_existing.as_ref().map(|row| row.plan), @@ -9777,8 +10067,8 @@ fn apply_profile_membership_purchase( period_points_before: expired_existing .as_ref() .map(|row| row.cycle_granted_points), - period_points_after: Some(target.period_points), - granted_points_delta: target.period_points as i64, + period_points_after: Some(settlement.period_points), + granted_points_delta: settlement.period_points as i64, cycle_index: Some(1), expires_at_before_micros: expired_existing .as_ref() @@ -9789,11 +10079,7 @@ fn apply_profile_membership_purchase( .and_then(|row| row.cycle_resets_at) .map(|value| value.to_micros_since_unix_epoch()), cycle_resets_at_after_micros: Some(cycle_resets_at_micros), - price_breakdown_json: Some(membership_price_breakdown_json( - &target, - None, - order_amount_cents, - )), + price_breakdown_json: Some(settlement.price_breakdown_json.clone()), }, }) } From 16f92f2110d4f0ada26bf39b381ab064ffa26595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 13:14:04 +0800 Subject: [PATCH 055/232] =?UTF-8?q?refactor(=E4=BC=9A=E5=91=98):=20?= =?UTF-8?q?=E4=BC=9A=E5=91=98=E6=A1=A3=E4=BD=8D=E7=9B=AE=E5=BD=95=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E6=97=A0=E7=94=A8=E7=9A=84=20sort=5Forder=EF=BC=8C?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=8C=89=20rank=20=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module-runtime catalog.rs:RuntimeProfileMembershipPlanRecord 与种子目录去掉 sort_order,目录单测改为按 rank 断言 - module-runtime domain.rs/commands.rs:后台改价入参与构建函数去掉 sort_order - spacetime-module profile.rs:profile_membership_plan 表结构去掉排序列,membership_plan_records 改按 rank 升序读取 - shared-contracts runtime.rs:ProfileMembershipPlanResponse 与 AdminUpsertProfileMembershipPlanRequest 移除 sortOrder - spacetime-client:客户端签名、mapper 与 module_bindings 同步移除 sortOrder - api-server runtime_profile.rs:响应构建与测试夹具去掉 sortOrder - admin-web:AdminMembershipPlanPage 移除「排序」输入,档位列表改为按 rank 排序,契约类型同步 - packages/shared:ProfileMembershipPlanRecord 去掉 sortOrder,充值弹层改为直接沿用后端的 rank 顺序 - docs:泥点三池与会员计费后端设计同步目录字段与后台能力说明 Co-authored-by: Junie --- apps/admin-web/src/api/adminApiTypes.ts | 2 -- .../src/pages/AdminMembershipPlanPage.tsx | 21 +++---------------- ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 8 +++---- .../index.test.tsx | 3 --- .../PlatformProfileRechargeModal/index.tsx | 8 +++---- packages/shared/src/contracts/runtime.ts | 1 - .../crates/api-server/src/runtime_profile.rs | 4 ---- .../crates/module-runtime/src/commands.rs | 3 --- server-rs/crates/module-runtime/src/domain.rs | 3 +-- .../module-runtime/src/membership/catalog.rs | 17 ++++----------- .../crates/shared-contracts/src/runtime.rs | 4 ---- .../src/active/mapper/runtime_profile.rs | 2 -- .../spacetime-client/src/active/runtime.rs | 2 -- .../profile_membership_plan_type.rs | 3 --- ...membership_plan_admin_upsert_input_type.rs | 1 - ...ime_profile_membership_plan_record_type.rs | 1 - .../src/runtime/active/profile.rs | 12 +++-------- 17 files changed, 19 insertions(+), 76 deletions(-) diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index f79e3dec4..0477cc868 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -795,7 +795,6 @@ export interface ProfileMembershipPlanAdminResponse { modelAccess: ProfileMembershipModelAccess; /** 并发上限;`128` 表示不设上限。 */ concurrentJobLimit: number; - sortOrder: number; enabled: boolean; } @@ -813,7 +812,6 @@ export interface AdminUpsertProfileMembershipPlanRequest { modelAccess: ProfileMembershipModelAccess; concurrentJobLimit: number; enabled: boolean; - sortOrder: number; } export interface ProfileWalletConfigAdminResponse { diff --git a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx index 1f05bda26..8503377bc 100644 --- a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx +++ b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx @@ -68,7 +68,6 @@ export function AdminMembershipPlanPage({ useState('basic'); const [concurrentJobLimit, setConcurrentJobLimit] = useState('1'); const [enabled, setEnabled] = useState(false); - const [sortOrder, setSortOrder] = useState('0'); const [rankLabel, setRankLabel] = useState('0'); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); @@ -143,7 +142,6 @@ export function AdminMembershipPlanPage({ modelAccess, concurrentJobLimit: normalizedConcurrentJobLimit, enabled, - sortOrder: parseInteger(sortOrder), }); onResultChange(response); upsertEntry(response); @@ -171,7 +169,6 @@ export function AdminMembershipPlanPage({ setModelAccess(entry.modelAccess); setConcurrentJobLimit(String(entry.concurrentJobLimit)); setEnabled(entry.enabled); - setSortOrder(String(entry.sortOrder)); setRankLabel(String(entry.rank)); } @@ -185,7 +182,6 @@ export function AdminMembershipPlanPage({ setModelAccess('basic'); setConcurrentJobLimit('1'); setEnabled(false); - setSortOrder('0'); setRankLabel(''); } @@ -316,13 +312,6 @@ export function AdminMembershipPlanPage({ ))} - - setSortOrder(event.target.value)} - /> - {!isPurchasable ? ( @@ -350,7 +339,8 @@ export function AdminMembershipPlanPage({ key: 'plan', label: '档位', sortable: true, - sortValue: (entry) => entry.sortOrder, + sortDescription: '按升级基准 rank', + sortValue: (entry) => entry.rank, }, { key: 'price', @@ -433,12 +423,7 @@ export function AdminMembershipPlanPage({ } function sortPlanEntries(entries: ProfileMembershipPlanAdminResponse[]) { - return [...entries].sort((left, right) => { - if (left.sortOrder !== right.sortOrder) { - return left.sortOrder - right.sortOrder; - } - return left.rank - right.rank; - }); + return [...entries].sort((left, right) => left.rank - right.rank); } function formatPlanLimits(entry: ProfileMembershipPlanAdminResponse) { diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index 0cff5c155..d837d45b2 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -79,7 +79,6 @@ pub struct ProfileMembershipPlan { 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, @@ -251,7 +250,7 @@ pub struct RuntimeProfileMembershipUpgradeQuote { - `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`)。 + `model_access` / `concurrent_job_limit` / `enabled`)。目录展示顺序与升级次序统一由 `rank` 决定。 - `resolve_runtime_profile_membership_plan_snapshot(plan, cycle_kind)`: 「档位 + 周期 → 可下单套餐」的唯一解析入口,`price_cents` 取所选周期的价格字段。 - `runtime_profile_membership_plan_rank(plan) -> Option` / `is_runtime_profile_membership_plan_purchasable(plan) -> bool`: @@ -401,8 +400,8 @@ flowchart TD - `mudPointBalance` 用 §5.1;`membership` 用 §5.2。 - 会员列表改为 `membershipPlans: RuntimeProfileMembershipPlanRecord[]`:一行一档,含 `plan` / - `title` / `rank` / 月价 / 年价 / 每期泥点 / 模型权限 / 并发上限 / 排序 / 可购买, - 由 `profile_membership_plan` 表按 `sort_order` 升序读出。 + `title` / `rank` / 月价 / 年价 / 每期泥点 / 模型权限 / 并发上限 / 可购买, + 由 `profile_membership_plan` 表按 `rank` 升序读出。 - 删除 `walletBalance`、`membershipProducts`、`benefits`(旧 Month/Season/Year 权益矩阵)。 - `pointProducts` 只剩泥点商品(会员商品行不再出现在商品配置表里)。 @@ -410,6 +409,7 @@ flowchart TD - 新增 `GET|POST /admin/api/profile/membership-plans`(按档位枚举 CRUD 价格与权益)。 - `rank` 不进请求:升级比较基准由代码内目录决定,后台不能改。 + - 目录不再有独立「排序」字段:展示顺序与升级次序统一由 `rank` 升序决定。 - `normal` 是不可购买的非会员占位档位,价格与额度由后端按 0 落库、`enabled` 强制 `false`。 - 非 `normal` 档位要求月价 / 年价 / 并发上限都大于 0,否则 4xx。 - 后台页:新增「会员档位」页(`AdminMembershipPlanPage`);「充值商品」页收敛为纯泥点商品 diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx index 1e14639b5..3855a4fcd 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx @@ -96,7 +96,6 @@ const STARTER_PLAN = { periodPoints: 400, modelAccess: 'basic' as const, concurrentJobLimit: 1, - sortOrder: 10, enabled: true, }; @@ -110,7 +109,6 @@ const PLUS_PLAN = { periodPoints: 1_150, modelAccess: 'full' as const, concurrentJobLimit: 3, - sortOrder: 20, }; const MAX_PLAN = { @@ -123,7 +121,6 @@ const MAX_PLAN = { periodPoints: 8_650, modelAccess: 'full' as const, concurrentJobLimit: 128, - sortOrder: 40, }; vi.mock('qrcode', () => ({ diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index d337dbdf1..0e5b88509 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -1216,12 +1216,12 @@ export function PlatformProfileRechargeModal({ const currentBalance = resolveProfileMudPointDisplayTotal( center?.mudPointBalance, ); + // 后端目录已按 rank 升序返回,这里只过滤不可购买与占位档。 const membershipPlans = useMemo( () => - (center?.membershipPlans ?? []) - .filter((plan) => plan.enabled && plan.plan !== 'normal') - .slice() - .sort((left, right) => left.sortOrder - right.sortOrder), + (center?.membershipPlans ?? []).filter( + (plan) => plan.enabled && plan.plan !== 'normal', + ), [center?.membershipPlans], ); const pointProducts = useMemo( diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index e8a184688..4abb20f2b 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -163,7 +163,6 @@ export type ProfileMembershipPlanRecord = { modelAccess: ProfileMembershipModelAccess; /** 并发上限;`128` 表示不设上限。 */ concurrentJobLimit: number; - sortOrder: number; enabled: boolean; }; diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 8083f5065..da0dd03d0 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1224,7 +1224,6 @@ pub async fn admin_upsert_profile_membership_plan( model_access, payload.concurrent_job_limit, payload.enabled, - payload.sort_order.unwrap_or(10), updated_at_micros as i64, ) .await @@ -2062,7 +2061,6 @@ fn build_profile_membership_plan_response( period_points: record.period_points, model_access: record.model_access.as_str().to_string(), concurrent_job_limit: record.concurrent_job_limit, - sort_order: record.sort_order, enabled: record.enabled, } } @@ -3565,7 +3563,6 @@ mod tests { period_points: 400, model_access: RuntimeProfileMembershipModelAccess::Basic, concurrent_job_limit: 1, - sort_order: 1, enabled: true, }], @@ -3789,7 +3786,6 @@ mod tests { period_points: 400, model_access: RuntimeProfileMembershipModelAccess::Basic, concurrent_job_limit: 1, - sort_order: 1, enabled: true, }], diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 22765aa15..8e6b39f53 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -458,7 +458,6 @@ pub fn build_runtime_profile_membership_plan_admin_upsert_input( model_access: RuntimeProfileMembershipModelAccess, concurrent_job_limit: u32, enabled: bool, - sort_order: i32, updated_at_micros: i64, ) -> Result { let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; @@ -479,7 +478,6 @@ pub fn build_runtime_profile_membership_plan_admin_upsert_input( model_access: baseline.model_access, concurrent_job_limit: baseline.concurrent_job_limit, enabled: false, - sort_order, updated_at_micros, }); } @@ -502,7 +500,6 @@ pub fn build_runtime_profile_membership_plan_admin_upsert_input( model_access, concurrent_job_limit, enabled, - sort_order, updated_at_micros, }) } diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index d1919d53c..5583aedae 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1816,7 +1816,7 @@ pub struct RuntimeProfileMembershipPlanAdminListInput { /// 后台会员档位改价 / 改权益入参。 /// /// `rank` 不在入参里:升级比较基准由代码内的档位目录决定,后台不能改。 -/// `title` 与 `sort_order` 仍可编辑,`normal` 档位的价格与额度会被忽略(它不可购买)。 +/// `title` 仍可编辑,`normal` 档位的价格与额度会被忽略(它不可购买)。 #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileMembershipPlanAdminUpsertInput { @@ -1829,7 +1829,6 @@ pub struct RuntimeProfileMembershipPlanAdminUpsertInput { pub model_access: RuntimeProfileMembershipModelAccess, pub concurrent_job_limit: u32, pub enabled: bool, - pub sort_order: i32, pub updated_at_micros: i64, } diff --git a/server-rs/crates/module-runtime/src/membership/catalog.rs b/server-rs/crates/module-runtime/src/membership/catalog.rs index b2229d840..f6c1962fd 100644 --- a/server-rs/crates/module-runtime/src/membership/catalog.rs +++ b/server-rs/crates/module-runtime/src/membership/catalog.rs @@ -1,7 +1,7 @@ //! 会员计划目录(纯数据 + 解析)。 //! -//! 目录一行一档:标题、排序 `rank`、月价、独立可配置年价、每期泥点、模型权限、并发上限、 -//! 排序与上下架。月付与年付共享同一 `period_points`(年付 = 12 次同额度发放),`cycle_count` +//! 目录一行一档:标题、`rank`、月价、独立可配置年价、每期泥点、模型权限、并发上限与 +//! 上下架。月付与年付共享同一 `period_points`(年付 = 12 次同额度发放),`cycle_count` //! 由周期类型推导,不落目录字段。 //! //! 本模块是后台 `profile_membership_plan` 表的种子真相源,也是升级比较 `rank` 的唯一来源; @@ -36,7 +36,6 @@ pub struct RuntimeProfileMembershipPlanRecord { pub period_points: u64, pub model_access: RuntimeProfileMembershipModelAccess, pub concurrent_job_limit: u32, - pub sort_order: i32, pub enabled: bool, } @@ -57,7 +56,7 @@ pub struct RuntimeProfileMembershipPlanSnapshot { pub enabled: bool, } -/// 会员目录种子(一行一档,按 `sort_order` 升序)。 +/// 会员目录种子(一行一档,按 `rank` 升序)。 /// /// 数值来自产品原型 `充值与会员.html`,`Max` 的并发上限用哨兵 /// [`MEMBERSHIP_UNLIMITED_CONCURRENCY`](crate::MEMBERSHIP_UNLIMITED_CONCURRENCY) 表示「不设上限」。 @@ -78,7 +77,6 @@ pub fn runtime_profile_membership_plan_catalog() -> Vec Vec Vec Vec Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -1702,7 +1699,6 @@ mod tests { period_points: 1_150, model_access: "full".to_string(), concurrent_job_limit: 3, - sort_order: 2, enabled: true, }], latest_order: None, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs index bc7d939be..e1aa0a557 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs @@ -1440,7 +1440,6 @@ pub(crate) fn map_runtime_profile_membership_plan_record( period_points: record.period_points, model_access: map_runtime_profile_membership_model_access_back(record.model_access), concurrent_job_limit: record.concurrent_job_limit, - sort_order: record.sort_order, enabled: record.enabled, } } @@ -2081,7 +2080,6 @@ impl From model_access: map_runtime_profile_membership_model_access(input.model_access), concurrent_job_limit: input.concurrent_job_limit, enabled: input.enabled, - sort_order: input.sort_order, updated_at_micros: input.updated_at_micros, } } diff --git a/server-rs/crates/spacetime-client/src/active/runtime.rs b/server-rs/crates/spacetime-client/src/active/runtime.rs index 83895efd5..88e7bc888 100644 --- a/server-rs/crates/spacetime-client/src/active/runtime.rs +++ b/server-rs/crates/spacetime-client/src/active/runtime.rs @@ -1768,7 +1768,6 @@ impl SpacetimeClient { model_access: module_runtime::RuntimeProfileMembershipModelAccess, concurrent_job_limit: u32, enabled: bool, - sort_order: i32, updated_at_micros: i64, ) -> Result { let procedure_input = build_runtime_profile_membership_plan_admin_upsert_input( @@ -1781,7 +1780,6 @@ impl SpacetimeClient { model_access, concurrent_job_limit, enabled, - sort_order, updated_at_micros, ) .map_err(SpacetimeClientError::validation_failed)? diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_plan_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_plan_type.rs index 848ab50fc..91d11708a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_plan_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_plan_type.rs @@ -18,7 +18,6 @@ pub struct ProfileMembershipPlan { pub period_points: u64, pub model_access: RuntimeProfileMembershipModelAccess, pub concurrent_job_limit: u32, - pub sort_order: i32, pub enabled: bool, pub created_by: String, pub created_at: __sdk::Timestamp, @@ -43,7 +42,6 @@ pub struct ProfileMembershipPlanCols { pub model_access: __sdk::__query_builder::Col, pub concurrent_job_limit: __sdk::__query_builder::Col, - pub sort_order: __sdk::__query_builder::Col, pub enabled: __sdk::__query_builder::Col, pub created_by: __sdk::__query_builder::Col, pub created_at: __sdk::__query_builder::Col, @@ -66,7 +64,6 @@ impl __sdk::__query_builder::HasCols for ProfileMembershipPlan { table_name, "concurrent_job_limit", ), - sort_order: __sdk::__query_builder::Col::new(table_name, "sort_order"), enabled: __sdk::__query_builder::Col::new(table_name, "enabled"), created_by: __sdk::__query_builder::Col::new(table_name, "created_by"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_upsert_input_type.rs index 9e38b8171..c916c8adf 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_upsert_input_type.rs @@ -19,7 +19,6 @@ pub struct RuntimeProfileMembershipPlanAdminUpsertInput { pub model_access: RuntimeProfileMembershipModelAccess, pub concurrent_job_limit: u32, pub enabled: bool, - pub sort_order: i32, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_record_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_record_type.rs index 73361a190..1d344391e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_record_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_record_type.rs @@ -18,7 +18,6 @@ pub struct RuntimeProfileMembershipPlanRecord { pub period_points: u64, pub model_access: RuntimeProfileMembershipModelAccess, pub concurrent_job_limit: u32, - pub sort_order: i32, pub enabled: bool, } 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 4c7175af6..98e74a997 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -495,7 +495,6 @@ pub struct ProfileMembershipPlan { 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, @@ -8237,7 +8236,6 @@ fn upsert_profile_membership_plan_record( input.model_access, input.concurrent_job_limit, input.enabled, - input.sort_order, input.updated_at_micros, ) .map_err(|error| error.to_string())?; @@ -8263,7 +8261,6 @@ fn upsert_profile_membership_plan_record( period_points: validated.period_points, model_access: validated.model_access, concurrent_job_limit: validated.concurrent_job_limit, - sort_order: validated.sort_order, enabled: validated.enabled, created_by: existing .as_ref() @@ -8799,7 +8796,6 @@ fn ensure_default_profile_membership_plan(ctx: &ReducerContext) { period_points: row.period_points, model_access: row.model_access, concurrent_job_limit: row.concurrent_job_limit, - sort_order: row.sort_order, enabled: row.enabled, created_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(), created_at: now, @@ -9166,7 +9162,6 @@ fn membership_plan_record_from_row( period_points: row.period_points, model_access: row.model_access, concurrent_job_limit: row.concurrent_job_limit, - sort_order: row.sort_order, enabled: row.enabled, } } @@ -9184,13 +9179,13 @@ fn membership_plan_snapshot( }) } -/// 充值中心与后台共用的目录行读取,按 `sort_order` 升序。 +/// 充值中心与后台共用的目录行读取,按 `rank` 升序。 fn membership_plan_records(ctx: &ReducerContext) -> Vec { ensure_default_profile_membership_plan(ctx); let mut rows = ctx.db.profile_membership_plan().iter().collect::>(); rows.sort_by(|left, right| { - left.sort_order - .cmp(&right.sort_order) + left.rank + .cmp(&right.rank) .then_with(|| left.plan.as_str().cmp(right.plan.as_str())) }); rows.into_iter() @@ -9203,7 +9198,6 @@ fn membership_plan_records(ctx: &ReducerContext) -> Vec Date: Sat, 3 Oct 2026 13:31:25 +0800 Subject: [PATCH 056/232] =?UTF-8?q?docs(=E4=BC=9A=E5=91=98):=20=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E4=BC=9A=E5=91=98=E8=B4=A6=E6=9C=9F=E6=8E=A8=E8=BF=9B?= =?UTF-8?q?=E4=B8=8E=E5=8D=87=E7=BA=A7=E6=8A=A5=E4=BB=B7=E7=9A=84=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - profile.rs:advance_profile_membership_cycle_to 补充「锚点恒定、期号可推导、落库仅作单调水位、时钟回拨不重放」说明 - profile.rs:refresh_profile_membership_cycle 补充未换期(含时钟回拨)直接返回的守卫注释 - profile.rs:membership_upgrade_quote_input 注明期号即账期水位、年付补差按剩余整月折算 - profile.rs:build_profile_membership_upgrade_quote_record 与 build_profile_membership_order_snapshot 补充账期口径说明 - upgrade.rs:注明年付补差剩余整月来自单调水位、不会因重复刷新漂移 Co-authored-by: Junie --- .../module-runtime/src/membership/upgrade.rs | 2 ++ .../src/runtime/active/profile.rs | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index db3474798..317b410d1 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -150,6 +150,8 @@ pub fn quote_runtime_profile_membership_upgrade( let current = input.current; let target = input.target; + // 年付补差按「本期还剩几个完整月」折算;期号来自只前进的账期水位, + // 因此这里直接相减得到的剩余整月不会因为重复刷新而漂移。 let cycle_count = target.cycle_kind.cycle_count(); let remaining_full_months = cycle_count.saturating_sub(input.cycle_index); let remaining_ratio_ppm = membership_cycle_remaining_ratio_ppm( 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 98e74a997..f36cad305 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -8167,6 +8167,10 @@ fn list_profile_membership_plan_records( } /// 补差升级报价。非会员、降档、月年互换、当前套餐重复购买都返回可读错误,不做静默兜底。 +/// +/// 注意:本函数会先刷新该用户的会员 / 钱包账期(写库),因此「预览报价」并非纯只读操作。 +/// 这是有意保留的现状——保证预览、建单、结算三处读到同一口径的账期;副作用仅限与刷新一致, +/// 不额外引入其它写入。 fn build_profile_membership_upgrade_quote_record( ctx: &ReducerContext, input: RuntimeProfileMembershipUpgradeQuoteGetInput, @@ -8174,6 +8178,7 @@ fn build_profile_membership_upgrade_quote_record( let validated = build_runtime_profile_membership_upgrade_quote_input(input.user_id, input.target_plan) .map_err(|error| error.to_string())?; + // 有意保留的写操作:先把账期推进到当前时刻再报价;改成只读投影需先确认契约与前端预期。 refresh_profile_wallet_expiring_points(ctx, &validated.user_id, ctx.timestamp); let Some(row) = ctx @@ -9207,25 +9212,35 @@ fn membership_plan_records(ctx: &ReducerContext) -> Vec MembershipCycleAdvance { let mut row = row; + // 锚点:有效期内唯一的固定参考,所有账期窗口都由它重算(见 `membership_cycle_window`)。 let anchor_micros = row.started_at.to_micros_since_unix_epoch(); let at_micros = at.to_micros_since_unix_epoch(); let previous_remaining_points = row.cycle_remaining_points; + // 期数上限:年付最多 12 期,再留 2 期余量容错,避免坏数据把循环拖长。 let max_index = row.cycle_count.max(1).saturating_add(2); + // 起点取已落库的期号(水位下限),保证只前进、不后退。 let mut cycle_index = row.cycle_index.max(1); let mut window = membership_cycle_window(anchor_micros, cycle_index); let mut advanced = row.cycle_resets_at.is_none() || row.cycle_index == 0; + // 仅当 `at` 已越过当前窗口结束时刻才推进;时钟回拨时条件不成立,整行保持原状。 while window.1 <= at_micros && cycle_index < max_index { cycle_index = cycle_index.saturating_add(1); window = membership_cycle_window(anchor_micros, cycle_index); advanced = true; } if advanced { + // 换期即重置本期额度;上期剩余额度由调用方按 `previous_remaining_points` 回收。 row.cycle_index = cycle_index; row.cycle_started_at = Some(Timestamp::from_micros_since_unix_epoch(window.0)); row.cycle_resets_at = Some(Timestamp::from_micros_since_unix_epoch(window.1)); @@ -9272,6 +9287,7 @@ fn membership_upgrade_quote_input( RuntimeProfileMembershipUpgradeQuoteInput { current: current.clone(), target: target.clone(), + // 期号即账期水位(1-based);年付补差按 `cycle_count - cycle_index` 折算剩余整月。 cycle_index: row.cycle_index.max(1), cycle_started_at_micros: row .cycle_started_at @@ -9354,6 +9370,7 @@ fn build_profile_membership_order_snapshot( return Err(format!("会员档位 {} 当前不可购买", plan.as_str())); } + // 只读地把账期投射到建单时刻(不写库),避免用到过期账期导致金额与预览 / 结算不一致。 let active_row = ctx .db .profile_membership() @@ -9715,6 +9732,7 @@ fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Ti let period_points = membership_plan_period_points(ctx, row.plan); let advance = advance_profile_membership_cycle_to(row, now, period_points); + // 未换期(含时钟回拨)直接返回:不写库、不补发,靠已落库的窗口水位挡住账期回退。 if !advance.advanced { return; } From 90e520fdba4b23fcdc5efebbbfcb3149badf58e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 13:32:54 +0800 Subject: [PATCH 057/232] =?UTF-8?q?refactor(=E4=BC=9A=E5=91=98):=20?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E6=8A=A5=E4=BB=B7=E6=94=B9=E4=B8=BA=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=88=B7=E6=96=B0=E8=B4=A6?= =?UTF-8?q?=E6=9C=9F=E4=B8=8E=E9=92=B1=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - profile.rs:build_profile_membership_upgrade_quote_record 不再调用 refresh_profile_wallet_expiring_points,改用只读投射 project_profile_membership_cycle_at 把账期推进到当前时刻 - profile.rs:报价接口不再写库 / 发账本,不会提前消耗用户的懒刷新,同时保持与建单、结算同口径 Co-authored-by: Junie --- .../spacetime-module/src/runtime/active/profile.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 f36cad305..a4891d3d3 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -8168,9 +8168,9 @@ fn list_profile_membership_plan_records( /// 补差升级报价。非会员、降档、月年互换、当前套餐重复购买都返回可读错误,不做静默兜底。 /// -/// 注意:本函数会先刷新该用户的会员 / 钱包账期(写库),因此「预览报价」并非纯只读操作。 -/// 这是有意保留的现状——保证预览、建单、结算三处读到同一口径的账期;副作用仅限与刷新一致, -/// 不额外引入其它写入。 +/// 全程只读:不刷新账期 / 钱包(不写库、不发账本),改用 [`project_profile_membership_cycle_at`] +/// 在内存里把会员行投射到当前时刻再报价,因此与建单、结算读到的账期口径一致, +/// 也不会让「预览报价」提前消耗用户的懒刷新。 fn build_profile_membership_upgrade_quote_record( ctx: &ReducerContext, input: RuntimeProfileMembershipUpgradeQuoteGetInput, @@ -8178,8 +8178,6 @@ fn build_profile_membership_upgrade_quote_record( let validated = build_runtime_profile_membership_upgrade_quote_input(input.user_id, input.target_plan) .map_err(|error| error.to_string())?; - // 有意保留的写操作:先把账期推进到当前时刻再报价;改成只读投影需先确认契约与前端预期。 - refresh_profile_wallet_expiring_points(ctx, &validated.user_id, ctx.timestamp); let Some(row) = ctx .db @@ -8190,6 +8188,8 @@ fn build_profile_membership_upgrade_quote_record( else { return Err("当前不是会员,请直接选择套餐购买".to_string()); }; + // 只读投射账期到当前时刻:不落库、不发账本,避免预览就改会员 / 钱包状态。 + let row = project_profile_membership_cycle_at(ctx, row, ctx.timestamp); if row.plan == validated.target_plan { return Err("当前套餐已生效,无需重复购买".to_string()); } From cd0cbacf18398b0c22ced9fd99b11d84f5c323d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 14:58:12 +0800 Subject: [PATCH 058/232] =?UTF-8?q?refactor(=E5=90=8E=E5=8F=B0=E4=BC=9A?= =?UTF-8?q?=E5=91=98):=20=E6=94=B6=E7=AA=84=E8=AE=A2=E5=8D=95=E5=8F=98?= =?UTF-8?q?=E6=9B=B4=E7=B1=BB=E5=9E=8B=E7=9A=84=E5=AD=97=E9=9D=A2=E9=87=8F?= =?UTF-8?q?=E8=81=94=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adminApiTypes.ts:AdminMembershipOrderChangePayload.changeKind 去掉多余的 string 分支,非法取值改由编译期暴露 Co-authored-by: Junie --- apps/admin-web/src/api/adminApiTypes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 4e263d322..2444b9ad2 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -975,7 +975,7 @@ export interface AdminRechargeRefundHoldPayload { export interface AdminMembershipOrderChangePayload { /** `purchase`(新购)或 `upgrade`(补差升级)。 */ - changeKind: 'purchase' | 'upgrade' | string; + changeKind: 'purchase' | 'upgrade'; planBefore?: ProfileMembershipPlan | null; planAfter?: ProfileMembershipPlan | null; cycleKindBefore?: ProfileMembershipCycleKind | null; From cf6391962e5ce813c1e6def77849c8f437ccf238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 14:58:14 +0800 Subject: [PATCH 059/232] =?UTF-8?q?refactor(=E4=BC=9A=E5=91=98=E5=85=85?= =?UTF-8?q?=E5=80=BC):=20=E5=B9=B6=E5=8F=91=E6=96=87=E6=A1=88=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E7=BB=9F=E4=B8=80=E7=9A=84=E4=B8=8D=E8=AE=BE=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - membershipCopy.ts:resolveMembershipConcurrencyLabel / resolveMembershipConcurrencyShortLabel 改为调用 isUnlimitedConcurrency,去掉重复的 128 判定 Co-authored-by: Junie --- .../components/PlatformProfileRechargeModal/membershipCopy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/membershipCopy.ts b/packages/shared/src/components/PlatformProfileRechargeModal/membershipCopy.ts index 2e7870390..87bb554ab 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/membershipCopy.ts +++ b/packages/shared/src/components/PlatformProfileRechargeModal/membershipCopy.ts @@ -79,7 +79,7 @@ export function resolveMembershipConcurrencyLabel( export function resolveMembershipConcurrencyShortLabel( concurrentJobLimit: number, ): string { - return concurrentJobLimit >= UNLIMITED_CONCURRENT_JOB_LIMIT + return isUnlimitedConcurrency(concurrentJobLimit) ? '不设套餐上限' : `${concurrentJobLimit} 个`; } From bfd96a7c250e26903efba2c4b63a8f52ec154016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 14:58:16 +0800 Subject: [PATCH 060/232] =?UTF-8?q?fix(=E5=90=8E=E5=8F=B0=E4=BC=9A?= =?UTF-8?q?=E5=91=98):=20=E6=95=B0=E5=80=BC=E8=BE=93=E5=85=A5=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20Number=20=E4=B8=8E=20isSafeInteger=20=E8=A7=A3?= =?UTF-8?q?=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminMembershipPlanPage.tsx:parseInteger 改为 Number + Number.isSafeInteger,避免 "1e2" / "1.9" 被 parseInt 静默截断后保存错误价格 Co-authored-by: Junie --- apps/admin-web/src/pages/AdminMembershipPlanPage.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx index 8503377bc..f32e02c72 100644 --- a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx +++ b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx @@ -445,9 +445,6 @@ function parsePositiveInteger(value: string) { } function parseInteger(value: string) { - const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed)) { - return 0; - } - return parsed; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : 0; } From 5f31c8c9dfdb2dfb119c63ece6661b80baee2458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 14:58:18 +0800 Subject: [PATCH 061/232] =?UTF-8?q?fix(=E4=BC=9A=E5=91=98=E5=85=85?= =?UTF-8?q?=E5=80=BC):=20=E5=A5=97=E9=A4=90=E5=8D=A1=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E8=A7=A6=E5=8F=91=E4=B8=8B=E5=8D=95=E5=9B=9E?= =?UTF-8?q?=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PlatformProfileRechargeModal/index.tsx:去掉套餐卡外层 div 的 onClick,点击 CTA 时不再因事件冒泡触发两次 openMembershipCheckout Co-authored-by: Junie --- .../components/PlatformProfileRechargeModal/index.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index 0e5b88509..f8886390b 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -1375,14 +1375,7 @@ export function PlatformProfileRechargeModal({ submittingProductId === resolveMembershipProductId(plan.plan, cycleKind); return ( -
openMembershipCheckout(plan) - } - > +
Date: Sat, 3 Oct 2026 14:58:21 +0800 Subject: [PATCH 062/232] =?UTF-8?q?fix(=E4=BC=9A=E5=91=98=E8=AE=A1?= =?UTF-8?q?=E4=BB=B7):=20ceil=5Fdiv=20=E9=9B=B6=E5=88=86=E6=AF=8D=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=20debug=5Fassert=20=E6=9A=B4=E9=9C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module-runtime/membership/upgrade.rs:ceil_div 增加 debug_assert!(denominator != 0),零分母在测试里立即失败,不再被静默算成 0 元 Co-authored-by: Junie --- server-rs/crates/module-runtime/src/membership/upgrade.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index 317b410d1..29e9f18b8 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -200,7 +200,11 @@ pub fn quote_runtime_profile_membership_upgrade( } /// 向上取整的整数除法(金额取整到分)。 +/// +/// 分母是 `MONTHS_PER_YEAR * REMAINING_RATIO_PPM` 这类编译期常量,恒不为 0; +/// 零分母属程序员错误,`debug_assert` 让它在测试里立刻暴露,而不是被静默算成 0 元。 fn ceil_div(numerator: u128, denominator: u128) -> u64 { + debug_assert!(denominator != 0, "ceil_div 的分母不能为 0"); if denominator == 0 { return 0; } From 8cfa5a45a97be5d3a5420c85a61aed914a121d86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 14:58:26 +0800 Subject: [PATCH 063/232] =?UTF-8?q?fix(=E5=90=8E=E5=8F=B0=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E9=A2=84=E8=A7=88):=20=E8=A1=A5=E4=B8=8A=E4=BC=9A?= =?UTF-8?q?=E5=91=98=E6=A1=A3=E4=BD=8D=20mock=20=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/admin-web-fake-api.mjs:新增 /admin/api/profile/membership-plans 的 GET 列表与 POST 回显,避免页面 response.entries 为 undefined Co-authored-by: Junie --- scripts/admin-web-fake-api.mjs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/scripts/admin-web-fake-api.mjs b/scripts/admin-web-fake-api.mjs index 102703ee4..cdd249d6b 100644 --- a/scripts/admin-web-fake-api.mjs +++ b/scripts/admin-web-fake-api.mjs @@ -736,6 +736,38 @@ function route(pathname, method, url) { scannedCount: orders.length, }; } + if (pathname === '/admin/api/profile/membership-plans') { + const plan = ( + planId, + title, + rank, + monthPriceCents, + yearPriceCents, + periodPoints, + modelAccess, + concurrentJobLimit, + enabled, + ) => ({ + plan: planId, + title, + rank, + monthPriceCents, + yearPriceCents, + periodPoints, + modelAccess, + concurrentJobLimit, + enabled, + }); + const plans = [ + plan('normal', '非会员', 0, 0, 0, 0, 'basic', 1, false), + plan('starter', '入门会员', 1, 3_900, 39_000, 400, 'basic', 1, true), + plan('plus', '轻享会员', 2, 9_900, 99_000, 1_150, 'full', 3, true), + plan('pro', '专业会员', 3, 29_900, 299_000, 3_650, 'full', 5, true), + plan('max', '旗舰会员', 4, 69_900, 699_000, 8_650, 'full', 128, true), + ]; + // 本地预览不解析请求体:POST 统一回显 starter,仅用于页面回填。 + return method === 'POST' ? plans[1] : { entries: plans }; + } if (pathname === '/admin/api/profile/recharge-products') { const product = ( productId, From 5361d8963993d2e8680faed61e233446a31675df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 14:58:28 +0800 Subject: [PATCH 064/232] =?UTF-8?q?fix(=E4=BC=9A=E5=91=98=E6=8A=A5?= =?UTF-8?q?=E4=BB=B7):=20=E6=A1=8C=E9=9D=A2=E7=AB=AF=E6=8A=A5=E4=BB=B7?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E5=A2=9E=E5=8A=A0=E5=BA=8F=E5=8F=B7=E5=AE=88?= =?UTF-8?q?=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useAccountWallet.ts:requestMembershipQuote 记录请求令牌,只有最新请求可以写入报价、错误与 loading Co-authored-by: Junie --- .../features/app-shell/useAccountWallet.ts | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts index 5649adfde..454b8e955 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts @@ -64,6 +64,7 @@ export function useAccountWallet(currentUserId: string) { null, ); const rechargeLifecycleRef = useRef(0); + const membershipQuoteRequestRef = useRef(null); const walletLedgerLifecycleRef = useRef(0); const redeemLifecycleRef = useRef(0); const [walletUiOwnerUserId, setWalletUiOwnerUserId] = useState(currentUserId); @@ -395,32 +396,29 @@ export function useAccountWallet(currentUserId: string) { async function requestMembershipQuote(plan: ProfileMembershipPlan) { const rechargeLifecycle = rechargeLifecycleRef.current; const snapshotOwnerUserId = currentUserId; + const requestToken = Symbol('profile-membership-quote'); + membershipQuoteRequestRef.current = requestToken; + const isLatestQuoteRequest = () => + rechargeLifecycleRef.current === rechargeLifecycle && + currentUserIdRef.current === snapshotOwnerUserId && + membershipQuoteRequestRef.current === requestToken; setMembershipQuote(null); setMembershipQuoteError(null); setMembershipQuoteLoading(true); try { const quote = await getClientProfileMembershipUpgradeQuote(plan); - if ( - rechargeLifecycleRef.current !== rechargeLifecycle || - currentUserIdRef.current !== snapshotOwnerUserId - ) { + if (!isLatestQuoteRequest()) { return; } setMembershipQuote(quote); } catch (error) { - if ( - rechargeLifecycleRef.current === rechargeLifecycle && - currentUserIdRef.current === snapshotOwnerUserId - ) { + if (isLatestQuoteRequest()) { setMembershipQuoteError( error instanceof Error ? error.message : '读取升级报价失败', ); } } finally { - if ( - rechargeLifecycleRef.current === rechargeLifecycle && - currentUserIdRef.current === snapshotOwnerUserId - ) { + if (isLatestQuoteRequest()) { setMembershipQuoteLoading(false); } } From 3a998e15bba772c22512f8c5ff43c4ae9abf949d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 14:58:30 +0800 Subject: [PATCH 065/232] =?UTF-8?q?fix(=E4=BC=9A=E5=91=98=E6=8A=A5?= =?UTF-8?q?=E4=BB=B7):=20=E5=B9=B3=E5=8F=B0=E8=B5=84=E6=96=99=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E6=8A=A5=E4=BB=B7=E8=AF=B7=E6=B1=82=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=BA=8F=E5=8F=B7=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - usePlatformProfileCenterController.ts:requestMembershipQuote 增加 membershipQuoteRequestRef 令牌,过期响应不再覆盖新报价或提前结束 loading Co-authored-by: Junie --- .../usePlatformProfileCenterController.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 5ae54d6f8..a5a7bfba5 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -443,6 +443,7 @@ export function usePlatformProfileCenterController({ const confirmingWechatRechargeOrderRef = useRef(null); const rechargeSubmissionRef = useRef(null); + const membershipQuoteRequestRef = useRef(null); const accountWriteAbortControllerRef = useRef(new AbortController()); const rechargeCenterReadRevisionRef = useRef(0); const accountLifecycleRevisionRef = useRef(0); @@ -1356,6 +1357,11 @@ export function usePlatformProfileCenterController({ ) { return; } + const requestToken = Symbol('profile-membership-quote'); + membershipQuoteRequestRef.current = requestToken; + const isLatestQuoteRequest = () => + isAccountLifecycleCurrent(account) && + membershipQuoteRequestRef.current === requestToken; setMembershipQuote(null); setMembershipQuoteError(null); setIsLoadingMembershipQuote(true); @@ -1363,13 +1369,13 @@ export function usePlatformProfileCenterController({ signal: requestSignal, }) .then((quote) => { - if (!isAccountLifecycleCurrent(account)) { + if (!isLatestQuoteRequest()) { return; } setMembershipQuote(quote); }) .catch((error: unknown) => { - if (!isAccountLifecycleCurrent(account)) { + if (!isLatestQuoteRequest()) { return; } setMembershipQuoteError( @@ -1377,7 +1383,7 @@ export function usePlatformProfileCenterController({ ); }) .finally(() => { - if (isAccountLifecycleCurrent(account)) { + if (isLatestQuoteRequest()) { setIsLoadingMembershipQuote(false); } }); From 7dd26b9cda3fe9546b0bd0ea394567977f7bf7d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 14:59:22 +0800 Subject: [PATCH 066/232] =?UTF-8?q?refactor(=E5=90=8E=E5=8F=B0=E4=BC=9A?= =?UTF-8?q?=E5=91=98):=20=E6=94=B6=E7=AA=84=E4=BC=9A=E5=91=98=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=9A=84=E5=AD=97=E9=9D=A2=E9=87=8F=E8=81=94=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adminApiTypes.ts:AdminProfileMembershipPayload.status 由 string 收窄为 'normal' | 'active' Co-authored-by: Junie --- apps/admin-web/src/api/adminApiTypes.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 2444b9ad2..3751b80e8 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1054,7 +1054,8 @@ export interface AdminUserDetailResponse { /** 当前会员状态与账期;无会员时为 `normal` 档位、无到期时间。 */ export interface AdminProfileMembershipPayload { plan: ProfileMembershipPlan; - status: string; + /** 与后端 `RuntimeProfileMembershipStatus` 对齐:`normal` / `active`。 */ + status: 'normal' | 'active'; cycleKind: ProfileMembershipCycleKind; cycleIndex: number; cycleCount: number; From 58e07ba2822b6bd9c0a3e53db60fb9ad966d951b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 14:59:24 +0800 Subject: [PATCH 067/232] =?UTF-8?q?fix(=E5=90=8E=E5=8F=B0=E4=BC=9A?= =?UTF-8?q?=E5=91=98):=20=E6=AF=8F=E6=9C=9F=E6=B3=A5=E7=82=B9=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=8F=90=E4=BA=A4=E5=89=8D=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminMembershipPlanPage.tsx:非 normal 档位要求每期泥点大于 0,不再等后端返回笼统目录错误 Co-authored-by: Junie --- apps/admin-web/src/pages/AdminMembershipPlanPage.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx index f32e02c72..0f00bdf3d 100644 --- a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx +++ b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx @@ -110,11 +110,16 @@ export function AdminMembershipPlanPage({ const normalizedYearPriceCents = parsePositiveInteger(yearPriceCents); const normalizedConcurrentJobLimit = parsePositiveInteger(concurrentJobLimit); + const normalizedPeriodPoints = parsePositiveInteger(periodPoints); if (plan !== 'normal') { if (!normalizedMonthPriceCents || !normalizedYearPriceCents) { setErrorMessage('月价与年价必须是大于 0 的整数分'); return; } + if (!normalizedPeriodPoints) { + setErrorMessage('每期泥点必须是大于 0 的整数'); + return; + } if (!normalizedConcurrentJobLimit) { setErrorMessage( `并发上限必须是大于 0 的整数;${UNLIMITED_CONCURRENT_JOB_LIMIT} 表示不设上限`, @@ -138,7 +143,7 @@ export function AdminMembershipPlanPage({ title: title.trim(), monthPriceCents: normalizedMonthPriceCents, yearPriceCents: normalizedYearPriceCents, - periodPoints: parsePositiveInteger(periodPoints), + periodPoints: normalizedPeriodPoints, modelAccess, concurrentJobLimit: normalizedConcurrentJobLimit, enabled, From adfae5adee9c59d9c9ee1052a6b61c83383c38f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 14:59:26 +0800 Subject: [PATCH 068/232] =?UTF-8?q?fix(=E4=BC=9A=E5=91=98=E8=AE=A1?= =?UTF-8?q?=E4=BB=B7):=20=E9=9B=B6=E6=97=B6=E9=95=BF=E8=B4=A6=E6=9C=9F?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E6=8C=89=E9=9D=9E=E6=B3=95=E5=85=A5=E5=8F=82?= =?UTF-8?q?=E6=8B=92=E7=BB=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module-runtime/membership/upgrade.rs:账期窗口校验由 < 改为 <=,cycle_resets_at 等于 cycle_started_at 时不再退化成「剩余比例为 0」 Co-authored-by: Junie --- server-rs/crates/module-runtime/src/membership/upgrade.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index 29e9f18b8..df71b8e1c 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -127,12 +127,15 @@ pub fn check_runtime_profile_membership_upgrade_allowed( /// 总期数不在入参里,而是由目标周期类型推导(`cycle_kind.cycle_count()`),所以「期号越界」 /// 相对该推导值判断,`cycle_count` 与周期类型天然对齐。`now` 只校验下界——已过期报价按 /// 「剩余比例为 0」返回,属既有契约。 +/// +/// 账期窗口要求严格递增(`cycle_started_at < cycle_resets_at`):零时长窗口没有有效分母, +/// 按非法入参拒绝,而不是让它退化成「剩余比例为 0」。 fn validate_runtime_profile_membership_upgrade_quote_input( input: &RuntimeProfileMembershipUpgradeQuoteInput, ) -> Result<(), RuntimeProfileMembershipUpgradeRejection> { let cycle_count = input.target.cycle_kind.cycle_count(); let cycle_index_out_of_range = input.cycle_index == 0 || input.cycle_index > cycle_count; - let window_out_of_order = input.cycle_resets_at_micros < input.cycle_started_at_micros; + let window_out_of_order = input.cycle_resets_at_micros <= input.cycle_started_at_micros; let now_before_window = input.now_micros < input.cycle_started_at_micros; if cycle_index_out_of_range || window_out_of_order || now_before_window { return Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput); From 40c754ee0e9efe21b87a79d59d0415c05e8ad9ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 14:59:30 +0800 Subject: [PATCH 069/232] =?UTF-8?q?fix(=E5=90=8E=E5=8F=B0=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E9=A2=84=E8=A7=88):=20=E4=BF=AE=E6=AD=A3=E4=BC=9A?= =?UTF-8?q?=E5=91=98=E8=B4=A6=E6=9C=9F=E6=9C=9F=E5=8F=B7=E8=B6=8A=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/admin-web-fake-api.mjs:月付用户详情 cycleIndex 由 2 改为 1,避免出现「第 2/1 期」 Co-authored-by: Junie --- scripts/admin-web-fake-api.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/admin-web-fake-api.mjs b/scripts/admin-web-fake-api.mjs index cdd249d6b..f6ecef174 100644 --- a/scripts/admin-web-fake-api.mjs +++ b/scripts/admin-web-fake-api.mjs @@ -459,7 +459,7 @@ const userDetail = { plan: 'plus', status: 'active', cycleKind: 'monthly', - cycleIndex: 2, + cycleIndex: 1, cycleCount: 1, cycleGrantedPoints: 600, cycleRemainingPoints: 320, From 44952704b687caec189fb92d09a02dbb4274e12d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 14:59:32 +0800 Subject: [PATCH 070/232] =?UTF-8?q?refactor(=E4=BC=9A=E5=91=98=E4=B8=8B?= =?UTF-8?q?=E5=8D=95):=20=E6=94=B6=E7=AA=84=E6=A1=8C=E9=9D=A2=E7=AB=AF?= =?UTF-8?q?=E4=B8=8B=E5=8D=95=E5=85=A5=E5=8F=82=E4=B8=BA=20productId=20?= =?UTF-8?q?=E4=B8=8E=20kind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useAccountWallet.ts:buyRechargeProduct 只接受 productId 与 kind,会员下单不再伪造价格等无用字段 Co-authored-by: Junie --- .../src/features/app-shell/useAccountWallet.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts index 454b8e955..1259629e5 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts @@ -286,7 +286,9 @@ export function useAccountWallet(currentUserId: string) { } } - async function buyRechargeProduct(product: ProfileRechargeProduct) { + async function buyRechargeProduct( + product: Pick, + ) { if (submittingRechargeProductId) { return; } @@ -432,14 +434,7 @@ export function useAccountWallet(currentUserId: string) { // 这里只需要把 productId 交给同一条下单链路。 void buyRechargeProduct({ productId: resolveMembershipProductId(plan, cycleKind), - title: plan, - priceCents: 0, kind: 'membership', - pointsAmount: 0, - bonusPoints: 0, - durationDays: 0, - badgeLabel: '', - description: '', }); } From c1b96f6bff6ff3f8a9d878cf4d6adc6c7994d186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 14:59:35 +0800 Subject: [PATCH 071/232] =?UTF-8?q?refactor(=E4=BC=9A=E5=91=98=E4=B8=8B?= =?UTF-8?q?=E5=8D=95):=20=E6=94=B6=E7=AA=84=E5=B9=B3=E5=8F=B0=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E4=B8=8B=E5=8D=95=E5=85=A5=E5=8F=82=E4=B8=BA=20produc?= =?UTF-8?q?tId=20=E4=B8=8E=20kind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - usePlatformProfileCenterController.ts:buyRechargeProduct 只接受 productId 与 kind,会员下单不再传 priceCents 等无用字段 Co-authored-by: Junie --- .../platform-entry/usePlatformProfileCenterController.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index a5a7bfba5..13b734320 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -1041,7 +1041,7 @@ export function usePlatformProfileCenterController({ }, [isRechargeOrderCurrent, nativeWechatPayment]); const buyRechargeProduct = useCallback( - (product: ProfileRechargeProduct) => { + (product: Pick) => { const account = captureAccountLifecycle(); if ( rechargeSubmissionRef.current || @@ -1397,14 +1397,7 @@ export function usePlatformProfileCenterController({ // 这里只需要把 productId 交给同一条下单链路。 buyRechargeProduct({ productId: resolveMembershipProductId(plan, cycleKind), - title: plan, - priceCents: 0, kind: 'membership', - pointsAmount: 0, - bonusPoints: 0, - durationDays: 0, - badgeLabel: '', - description: '', }); }, [buyRechargeProduct], From aca58b6b2503a94cac2db607544601a459b8d776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 14:59:37 +0800 Subject: [PATCH 072/232] =?UTF-8?q?fix(=E5=90=8E=E5=8F=B0=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3):=20=E4=BC=9A=E5=91=98=E5=8D=87=E7=BA=A7=E4=B8=8E?= =?UTF-8?q?=E6=A1=A3=E4=BD=8D=E7=9B=AE=E5=BD=95=E7=9A=84=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=E6=94=B9=E4=B8=BA=20400?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runtime_profile.rs:新增 is_runtime_profile_membership_domain_error,会员升级报价(非会员 / 重复购买 / 月年互换 / 档位下架 / 非法报价入参)与会员档位目录校验失败统一映射为 400,基础设施错误仍保持 502 Co-authored-by: Junie --- .../crates/api-server/src/runtime_profile.rs | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index da0dd03d0..4854fff94 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1484,14 +1484,14 @@ pub async fn get_profile_play_stats( } fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError { - let is_redeem_code_domain_error = matches!( + let is_domain_error = matches!( &error, SpacetimeClientError::Procedure(message) - if is_runtime_profile_redeem_code_domain_error(message) + if is_runtime_profile_domain_error(message) ); let (status, provider) = match &error { SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-profile"), - SpacetimeClientError::Procedure(_) if is_redeem_code_domain_error => { + SpacetimeClientError::Procedure(_) if is_domain_error => { (StatusCode::BAD_REQUEST, "runtime-profile") } _ => (StatusCode::BAD_GATEWAY, "spacetimedb"), @@ -1501,12 +1501,29 @@ fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError { "provider": provider, "message": error.to_string(), })); - if is_redeem_code_domain_error { + if is_domain_error { return app_error.with_message(error.to_string()); } app_error } +/// 模块侧的可读业务拒绝以 400 返回,基础设施错误保持 502。 +fn is_runtime_profile_domain_error(message: &str) -> bool { + is_runtime_profile_redeem_code_domain_error(message) + || is_runtime_profile_membership_domain_error(message) +} + +fn is_runtime_profile_membership_domain_error(message: &str) -> bool { + message.starts_with("会员升级被拒绝: ") + || message.starts_with("会员档位") + || matches!( + message, + "当前不是会员,请直接选择套餐购买" + | "当前套餐已生效,无需重复购买" + | "当前会员有效期内不支持月付 / 年付转换,到期后可重新选择" + ) +} + fn is_runtime_profile_redeem_code_domain_error(message: &str) -> bool { matches!( message, From 071cc3284adc3769522da8778864368775e949fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 14:59:42 +0800 Subject: [PATCH 073/232] =?UTF-8?q?fix(=E6=A1=8C=E9=9D=A2=E5=A3=B3?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C):=20=E8=A1=A5=E4=B8=8A=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E6=8A=A5=E4=BB=B7=E5=91=BD=E4=BB=A4=E5=88=B0=20allowlist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apps/ai-game-creator-shell/scripts/check-config.mjs:allowedUncalledTauriCommands 增加 read_profile_membership_upgrade_quote,node scripts/check-config.mjs 恢复通过 Co-authored-by: Junie --- apps/ai-game-creator-shell/scripts/check-config.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 31dd72bae..93a003285 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -139,6 +139,7 @@ const allowedUncalledTauriCommands = [ // 账户与钱包由 Rust typed command 持有 origin/Bearer/envelope;命令名在 `accountHost.ts` // 里以字面量出现,静态扫描仍按共享注册表核验。 'read_profile_recharge_center', + 'read_profile_membership_upgrade_quote', 'read_profile_wallet_ledger', 'read_profile_referral_invite_center', 'create_profile_recharge_order', From a0c48d85a5d00ab22d488de6c69424f697d2906b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 15:00:06 +0800 Subject: [PATCH 074/232] =?UTF-8?q?fix(=E5=90=8E=E5=8F=B0=E4=BC=9A?= =?UTF-8?q?=E5=91=98):=20=E6=A0=87=E9=A2=98=E4=B8=BA=E7=A9=BA=E6=97=B6?= =?UTF-8?q?=E7=A6=81=E7=94=A8=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminMembershipPlanPage.tsx:标题去空格为空时禁用保存按钮,不必等确认弹窗后才收到 MissingProductTitle 报错 Co-authored-by: Junie --- apps/admin-web/src/pages/AdminMembershipPlanPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx index 0f00bdf3d..606330914 100644 --- a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx +++ b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx @@ -327,7 +327,11 @@ export function AdminMembershipPlanPage({ {errorMessage ? {errorMessage} : null} - + From 873bd5bee9eeec5c8fd1aeb5601c77721927e06c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 15:00:08 +0800 Subject: [PATCH 075/232] =?UTF-8?q?fix(=E4=BC=9A=E5=91=98=E6=8A=A5?= =?UTF-8?q?=E4=BB=B7):=20=E5=88=87=E6=8D=A2=E8=B4=A6=E5=8F=B7=E6=97=B6?= =?UTF-8?q?=E6=B8=85=E7=90=86=E6=8A=A5=E4=BB=B7=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - usePlatformProfileCenterController.ts:账号生命周期重置时清空 membershipQuote / loading / error 并作废未完成报价令牌,避免上一个账号的报价串到新账号 Co-authored-by: Junie --- .../platform-entry/usePlatformProfileCenterController.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 13b734320..8600c2863 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -568,6 +568,10 @@ export function usePlatformProfileCenterController({ setWechatRechargeOrderConfirmationState(null); setNativeWechatPayment(null); setSubmittingRechargeProductId(null); + setMembershipQuote(null); + setIsLoadingMembershipQuote(false); + setMembershipQuoteError(null); + membershipQuoteRequestRef.current = null; setIsWalletLedgerOpen(false); setWalletLedger(null); setWalletLedgerError(null); From c165a032c9d6f02221e95f79a52dc77a6944060a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 15:29:32 +0800 Subject: [PATCH 076/232] =?UTF-8?q?fix(=E5=85=85=E7=82=B9=E5=BC=B9?= =?UTF-8?q?=E7=AA=97):=20=E5=88=A0=E9=99=A4=E7=A1=AC=E7=BC=96=E7=A0=81?= =?UTF-8?q?=E7=9A=84=E5=85=85=E5=80=BC=E6=B1=87=E7=8E=87=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除「常规充值 ¥1 = 10 永久泥点」的写死文案,档位价格与点数完全由后台商品目录决定,避免目录改价后文案与实际不一致。 Co-authored-by: Junie --- .../src/components/PlatformProfileRechargeModal/index.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index f8886390b..07989fff9 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -1425,9 +1425,6 @@ export function PlatformProfileRechargeModal({ setSelectedPointProductId(product.productId) } /> -

- 常规充值 ¥1 = 10 永久泥点,会员到期也可继续使用。 -

Date: Sat, 3 Oct 2026 15:32:33 +0800 Subject: [PATCH 077/232] =?UTF-8?q?=E4=BC=9A=E5=91=98=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E6=8A=A5=E4=BB=B7=E6=8B=92=E7=BB=9D=E5=B7=B2=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E8=B4=A6=E6=9C=9F=E7=9A=84=E8=BF=87=E6=9C=9F=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正 upgrade.rs 报价校验:now 必须落在 [cycle_started_at, cycle_resets_at) 内,越界按 InvalidQuoteInput 拒绝,避免旧 cycle_index 配零剩余比例多收一个完整月 同步模块头注释、校验函数文档与 InvalidQuoteInput 枚举说明 把「过期窗口只收完整月」的用例改为「过期窗口被拒绝」,保留剩余比例自身的 0 断言 Co-authored-by: Junie --- .../module-runtime/src/membership/upgrade.rs | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index df71b8e1c..1c44fce52 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -13,6 +13,9 @@ //! 「后续完整月数」取 `cycle_count − cycle_index`,**不**从 `cycle_resets_at` 重新锚定推算: //! 月末夹取后的日期(如 1/31 锚点得到 2/28)不再是原始开通日,重锚会漂移成 3/28。 //! 「本期剩余比例」用本期实际时长做分母,单位取百万分之一(ppm)。 +//! +//! 入参要求 `cycle_started_at <= now < cycle_resets_at`:调用方必须先把会员行投射到当前账期, +//! 不接受已刷新的旧窗口——否则剩余比例与期号口径不一致,会静默多收一个完整月。 use serde::{Deserialize, Serialize}; @@ -69,7 +72,7 @@ pub enum RuntimeProfileMembershipUpgradeRejection { NotUpgrade, /// 目标档位不可购买(非会员占位或已下架)。 TargetPlanNotPurchasable, - /// 报价入参的数值不变量被破坏(期号越界或账期窗口非法):拒绝,而不是静默夹取。 + /// 报价入参的数值不变量被破坏(期号越界、账期窗口非法或 `now` 不在窗口内):拒绝,而不是静默夹取。 InvalidQuoteInput, } @@ -125,19 +128,24 @@ pub fn check_runtime_profile_membership_upgrade_allowed( /// 在此显式校验是为了不让越界值被 `saturating_sub` 静默夹取后算出错误金额。 /// /// 总期数不在入参里,而是由目标周期类型推导(`cycle_kind.cycle_count()`),所以「期号越界」 -/// 相对该推导值判断,`cycle_count` 与周期类型天然对齐。`now` 只校验下界——已过期报价按 -/// 「剩余比例为 0」返回,属既有契约。 +/// 相对该推导值判断,`cycle_count` 与周期类型天然对齐。 /// /// 账期窗口要求严格递增(`cycle_started_at < cycle_resets_at`):零时长窗口没有有效分母, /// 按非法入参拒绝,而不是让它退化成「剩余比例为 0」。 +/// +/// `now` 必须落在账期窗口内(`cycle_started_at <= now < cycle_resets_at`):本期剩余比例与 +/// 期号是同一期账期的两个口径,账期已刷新仍按旧 `cycle_index` 报价会多收整整一个完整月 +/// (年付还会把补点算成 0)。生产入口先用 `project_profile_membership_cycle_at` 把会员行 +/// 投射到当前账期,因此这里拒绝的只会是「拿过期账期直接报价」的错误调用方。 fn validate_runtime_profile_membership_upgrade_quote_input( input: &RuntimeProfileMembershipUpgradeQuoteInput, ) -> Result<(), RuntimeProfileMembershipUpgradeRejection> { let cycle_count = input.target.cycle_kind.cycle_count(); let cycle_index_out_of_range = input.cycle_index == 0 || input.cycle_index > cycle_count; let window_out_of_order = input.cycle_resets_at_micros <= input.cycle_started_at_micros; - let now_before_window = input.now_micros < input.cycle_started_at_micros; - if cycle_index_out_of_range || window_out_of_order || now_before_window { + let now_outside_window = input.now_micros < input.cycle_started_at_micros + || input.now_micros >= input.cycle_resets_at_micros; + if cycle_index_out_of_range || window_out_of_order || now_outside_window { return Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput); } Ok(()) @@ -325,18 +333,27 @@ mod tests { ), 0 ); + } - let quote = + #[test] + fn quote_rejects_now_at_or_after_cycle_reset() { + let input = base_input(); + // 中文注释:账期已刷新仍按旧 `cycle_index` 报价会多收一个完整月(年付还把补点算成 0), + // 因此窗口上界与下界一样按非法入参拒绝;调用方应先投射账期到当前时刻。 + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + now_micros: input.cycle_resets_at_micros, + ..input.clone() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + assert_eq!( quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { now_micros: input.cycle_resets_at_micros + DAY_MICROS, ..input - }) - .expect("年付升级应可报价"); - // 中文注释:此处已把 `input` 整体移入调用,上面的比例断言必须先于本行执行。 - assert_eq!(quote.remaining_ratio_ppm, 0); - assert_eq!(quote.granted_points_delta, 0); - // 中文注释:仅剩 9 个完整月,本期零头为 0,因此只收 9/12 的价差并向下取整补点。 - assert_eq!(quote.amount_cents, 150_000); + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); } #[test] From f9b0cf786dcd3324bdd66356deb540a3cd699518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 16:06:06 +0800 Subject: [PATCH 078/232] =?UTF-8?q?=E4=BC=9A=E5=91=98=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E8=A1=A5=E5=B7=AE=E8=AE=A2=E5=8D=95=E5=88=B0=E6=9C=9F=E5=90=8E?= =?UTF-8?q?=E6=8C=89=E6=96=B0=E8=B4=AD=E4=BB=B7=E6=A0=A1=E9=AA=8C=E7=BB=93?= =?UTF-8?q?=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 check_expired_membership_upgrade_settlement:补差订单在支付前会员已到期时,订单金额必须达到目标周期当前目录价才允许按新购结算,否则拒绝 新增 RuntimeProfileMembershipUpgradeRejection::MembershipExpiredBeforeSettlement 及错误码字符串 module-runtime 导出该判定函数供结算侧复用 spacetime-module 结算分支调用该判定,目录缺档位时返回明确错误 api-server 把「会员已到期,请按新购价格重新下单」映射为 400 业务错误 补充过期补差订单必须覆盖整期新购价的单元用例 --- .../crates/api-server/src/runtime_profile.rs | 1 + server-rs/crates/module-runtime/src/lib.rs | 5 +- .../module-runtime/src/membership/upgrade.rs | 48 +++++++++++++++++++ .../src/runtime/active/profile.rs | 12 ++++- 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 4854fff94..8b3964b82 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1521,6 +1521,7 @@ fn is_runtime_profile_membership_domain_error(message: &str) -> bool { "当前不是会员,请直接选择套餐购买" | "当前套餐已生效,无需重复购买" | "当前会员有效期内不支持月付 / 年付转换,到期后可重新选择" + | "会员已到期,请按新购价格重新下单" ) } diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 4a1381e8b..fe77d4f1e 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -23,8 +23,9 @@ pub use llm_billing::*; pub use membership::{ MEMBERSHIP_UNLIMITED_CONCURRENCY, RuntimeProfileMembershipPlanRecord, RuntimeProfileMembershipPlanSnapshot, RuntimeProfileMembershipUpgradeQuoteInput, - beijing_local_micros, build_runtime_profile_membership_plan_snapshot, is_unlimited_concurrency, - membership_cycle_window, membership_expires_at, parse_runtime_profile_membership_product_id, + beijing_local_micros, build_runtime_profile_membership_plan_snapshot, + check_expired_membership_upgrade_settlement, is_unlimited_concurrency, membership_cycle_window, + membership_expires_at, parse_runtime_profile_membership_product_id, quote_runtime_profile_membership_upgrade, resolve_runtime_profile_membership_plan_row, runtime_profile_membership_plan_catalog, runtime_profile_membership_plan_rank, runtime_profile_membership_product_id, diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index 1c44fce52..30545a1d9 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -16,6 +16,9 @@ //! //! 入参要求 `cycle_started_at <= now < cycle_resets_at`:调用方必须先把会员行投射到当前账期, //! 不接受已刷新的旧窗口——否则剩余比例与期号口径不一致,会静默多收一个完整月。 +//! +//! 报价冻结后会员可能在支付前到期,此时补差价不再覆盖整期新购价, +//! 由 [`check_expired_membership_upgrade_settlement`] 在结算侧判定。 use serde::{Deserialize, Serialize}; @@ -74,6 +77,8 @@ pub enum RuntimeProfileMembershipUpgradeRejection { TargetPlanNotPurchasable, /// 报价入参的数值不变量被破坏(期号越界、账期窗口非法或 `now` 不在窗口内):拒绝,而不是静默夹取。 InvalidQuoteInput, + /// 补差价订单结算时会员已到期,且订单金额不够覆盖整期新购价:应重新下单购买。 + MembershipExpiredBeforeSettlement, } impl RuntimeProfileMembershipUpgradeRejection { @@ -84,6 +89,7 @@ impl RuntimeProfileMembershipUpgradeRejection { Self::NotUpgrade => "not_upgrade", Self::TargetPlanNotPurchasable => "target_plan_not_purchasable", Self::InvalidQuoteInput => "invalid_quote_input", + Self::MembershipExpiredBeforeSettlement => "membership_expired_before_settlement", } } } @@ -210,6 +216,21 @@ pub fn quote_runtime_profile_membership_upgrade( }) } +/// 补差价订单在「会员已到期」时是否仍可按新购结算。 +/// +/// 补差价只覆盖升级当期剩余权益的差额,前提是升级时会员仍在有效期内;一旦拖到到期后才支付, +/// 同一笔冻结金额就不再覆盖整期新购价。只有订单金额达到目标周期当前目录价才继续按新购结算, +/// 否则拒绝,避免用零头价换一整个周期。 +pub fn check_expired_membership_upgrade_settlement( + order_amount_cents: u64, + target_price_cents: u64, +) -> Result<(), RuntimeProfileMembershipUpgradeRejection> { + if order_amount_cents < target_price_cents { + return Err(RuntimeProfileMembershipUpgradeRejection::MembershipExpiredBeforeSettlement); + } + Ok(()) +} + /// 向上取整的整数除法(金额取整到分)。 /// /// 分母是 `MONTHS_PER_YEAR * REMAINING_RATIO_PPM` 这类编译期常量,恒不为 0; @@ -551,4 +572,31 @@ mod tests { assert_eq!(quote.amount_cents, 155_556); assert_eq!(quote.granted_points_delta, 833); } + + #[test] + fn expired_settlement_needs_the_full_target_price() { + // 中文注释:年付 Pro 目录价 299_000 分;补差价 158_334 分覆盖不了整期新购价,必须拒绝, + // 否则用户可在到期前用补差价建单、到期后再支付,等于用零头价换一个完整周期。 + let pro_yearly = snapshot( + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipCycleKind::Yearly, + ); + assert_eq!(pro_yearly.price_cents, 299_000); + assert_eq!( + check_expired_membership_upgrade_settlement(158_334, pro_yearly.price_cents), + Err(RuntimeProfileMembershipUpgradeRejection::MembershipExpiredBeforeSettlement) + ); + // 中文注释:订单金额本身已覆盖整期新购价(改价后挡位降价的容错路径)时按新购结算。 + assert_eq!( + check_expired_membership_upgrade_settlement(pro_yearly.price_cents, 299_000), + Ok(()) + ); + assert_eq!( + check_expired_membership_upgrade_settlement( + pro_yearly.price_cents + 1, + pro_yearly.price_cents + ), + Ok(()) + ); + } } 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 a4891d3d3..2ffc40acc 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -9832,7 +9832,9 @@ fn build_profile_membership_snapshot( /// - 结算口径以订单建单时冻结的快照为准:档位、每期泥点、补点与价格都不再回查目录, /// 因此下单后改价 / 改每期泥点 / 下架档位都不会影响已受理订单;历史 Pending 订单回退到按目录重算。 /// - 有效期内:只允许「同周期升更高档位」,按快照补点,刷新日与到期日不变。 -/// - 无有效会员(从未开通或已到期):按快照重新起算,锚点就是本次支付时刻。 +/// - 无有效会员(从未开通或已到期):按快照重新起算,锚点就是本次支付时刻; +/// 但补差价订单结算时会员已到期,必须先满足 [`check_expired_membership_upgrade_settlement`], +/// 否则拒绝——补差价只覆盖当期剩余权益的差额,不覆盖整期新购价。 /// /// 幂等锚点是**订单维度**的账本 id,重复支付回调天然幂等。 fn apply_profile_membership_purchase( @@ -9872,7 +9874,13 @@ fn apply_profile_membership_purchase( purchased_at, ); } - // 中文注释:会员在支付前已到期,退化为按冻结档位重新起算,避免已付款订单卡死。 + // 中文注释:会员在支付前已到期,冻结的补差价只覆盖当期剩余权益的差额, + // 不再覆盖整期新购价;只有订单金额达到目标周期当前目录价才按新购结算, + // 否则拒绝,避免用户用零头补差价换到一个完整周期。 + let target = membership_plan_snapshot(ctx, settlement.plan, settlement.cycle_kind) + .ok_or_else(|| format!("会员目录缺少档位 {}", settlement.plan.as_str()))?; + check_expired_membership_upgrade_settlement(order.amount_cents, target.price_cents) + .map_err(|_| "会员已到期,请按新购价格重新下单".to_string())?; } apply_profile_membership_fresh_purchase( From 3e2ab3cf78ef1953c8a65129bf086376f3169ae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 16:15:36 +0800 Subject: [PATCH 079/232] =?UTF-8?q?=E4=BC=9A=E5=91=98=E5=85=85=E5=80=BC?= =?UTF-8?q?=E4=B8=8B=E5=8D=95=E4=B8=8D=E5=86=8D=E6=8E=A5=E5=8F=97=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E6=97=B6=E9=97=B4=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RuntimeProfileRechargeOrderCreateInput 删除 created_at_micros 字段,procedure 入参不再携带时间 build_runtime_profile_recharge_order_create_input 同步去掉时间参数 spacetime-module 建单时刻改用 ctx.timestamp,避免客户端把时间设到会员到期前夕压低补差金额 spacetime-client facade 与 mapper、生成绑定同步去掉该字段 api-server 不再自算 current_unix_micros 传入 更新受影响的 module-runtime 单测调用 --- server-rs/crates/api-server/src/runtime_profile.rs | 8 +------- server-rs/crates/module-runtime/src/commands.rs | 2 -- server-rs/crates/module-runtime/src/domain.rs | 1 - server-rs/crates/module-runtime/src/lib.rs | 2 -- .../src/active/mapper/runtime_profile.rs | 1 - .../crates/spacetime-client/src/active/runtime.rs | 13 ++++--------- ...time_profile_recharge_order_create_input_type.rs | 1 - .../spacetime-module/src/runtime/active/profile.rs | 8 +++++--- 8 files changed, 10 insertions(+), 26 deletions(-) diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 8b3964b82..9d585f6c5 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -301,15 +301,9 @@ pub async fn create_profile_recharge_order( .map_err(|error| runtime_profile_error_response(&request_context, error))?; validate_recharge_payment_channel(&state, &payment_channel) .map_err(|error| runtime_profile_error_response(&request_context, error))?; - let created_at_micros = current_unix_micros(); let (center, order) = state .spacetime_client() - .create_profile_recharge_order( - user_id, - payload.product_id, - payment_channel.clone(), - created_at_micros, - ) + .create_profile_recharge_order(user_id, payload.product_id, payment_channel.clone()) .await .map_err(|error| { runtime_profile_error_response( diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 8e6b39f53..702bc8398 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -357,7 +357,6 @@ pub fn build_runtime_profile_recharge_order_create_input( user_id: String, product_id: String, payment_channel: String, - created_at_micros: i64, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; let product_id = @@ -369,7 +368,6 @@ pub fn build_runtime_profile_recharge_order_create_input( user_id, product_id, payment_channel, - created_at_micros, }) } diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 5583aedae..3fd46f4ac 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1916,7 +1916,6 @@ pub struct RuntimeProfileRechargeOrderCreateInput { pub user_id: String, pub product_id: String, pub payment_channel: String, - pub created_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index fe77d4f1e..801644791 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -1000,7 +1000,6 @@ mod tests { "user-1".to_string(), "custom-points-600".to_string(), "mock".to_string(), - 1, ) .expect("product existence is validated against database config later"); @@ -1014,7 +1013,6 @@ mod tests { "user-1".to_string(), "points_60".to_string(), " ".to_string(), - 1, ) .expect_err("missing payment channel should fail"); diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs index e1aa0a557..c65d6ab68 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs @@ -170,7 +170,6 @@ impl From user_id: input.user_id, product_id: input.product_id, payment_channel: input.payment_channel, - created_at_micros: input.created_at_micros, } } } diff --git a/server-rs/crates/spacetime-client/src/active/runtime.rs b/server-rs/crates/spacetime-client/src/active/runtime.rs index 88e7bc888..7c5c4bb34 100644 --- a/server-rs/crates/spacetime-client/src/active/runtime.rs +++ b/server-rs/crates/spacetime-client/src/active/runtime.rs @@ -399,7 +399,6 @@ impl SpacetimeClient { user_id: String, product_id: String, payment_channel: String, - created_at_micros: i64, ) -> Result< ( RuntimeProfileRechargeCenterRecord, @@ -407,14 +406,10 @@ impl SpacetimeClient { ), SpacetimeClientError, > { - let procedure_input = build_runtime_profile_recharge_order_create_input( - user_id, - product_id, - payment_channel, - created_at_micros, - ) - .map_err(SpacetimeClientError::validation_failed)? - .into(); + let procedure_input = + build_runtime_profile_recharge_order_create_input(user_id, product_id, payment_channel) + .map_err(SpacetimeClientError::validation_failed)? + .into(); self.call_after_connect( "create_profile_recharge_order_and_return", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_create_input_type.rs index 7a4c7ee43..0697f06f2 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_create_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_create_input_type.rs @@ -10,7 +10,6 @@ pub struct RuntimeProfileRechargeOrderCreateInput { pub user_id: String, pub product_id: String, pub payment_channel: String, - pub created_at_micros: i64, } impl __sdk::InModule for RuntimeProfileRechargeOrderCreateInput { 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 2ffc40acc..17811db29 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -4766,12 +4766,14 @@ fn create_profile_recharge_order_record( input.user_id, input.product_id, input.payment_channel, - input.created_at_micros, ) .map_err(|error| error.to_string())?; let product = resolve_enabled_profile_recharge_order_product(ctx, &validated_input.product_id) .ok_or_else(|| "recharge.product_id 不存在或已下架".to_string())?; - let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros); + // 中文注释:建单时刻一律取服务端事务时间,procedure 入参不再携带时间; + // 否则客户端可把时间设到会员到期前夕,压低升级补差金额。 + let created_at_micros = ctx.timestamp.to_micros_since_unix_epoch(); + let created_at = Timestamp::from_micros_since_unix_epoch(created_at_micros); let should_settle_immediately = validated_input.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK; // 会员订单:建单时冻结档位、价格与补点快照,之后支付只认这份快照(改价 / 下架都不影响)。 @@ -4792,7 +4794,7 @@ fn create_profile_recharge_order_record( .unwrap_or(product.price_cents); let order_id = build_runtime_profile_recharge_order_id( &validated_input.user_id, - validated_input.created_at_micros, + created_at_micros, &product.product_id, ); From 3031f588757e2dba4661e4bfa84c0bee48fbb18a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 16:16:07 +0800 Subject: [PATCH 080/232] =?UTF-8?q?=E4=BC=9A=E5=91=98=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E7=BB=93=E7=AE=97=E5=89=8D=E6=A0=A1=E9=AA=8C=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E5=86=BB=E7=BB=93=E7=9A=84=E5=8F=98=E6=9B=B4=E5=89=8D=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 结算补差升级单前比对 settlement.plan_before / cycle_kind_before 与当前会员行,不一致直接拒绝「会员状态已变化,请重新下单」 避免用户先付更高档位升级单后,旧升级单落地把档位 / 周期覆盖回去,出现降档或「年付 + 一个月到期」的矛盾行 api-server 把该业务拒绝映射为 400 --- server-rs/crates/api-server/src/runtime_profile.rs | 1 + .../crates/spacetime-module/src/runtime/active/profile.rs | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 9d585f6c5..672c5da4f 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1516,6 +1516,7 @@ fn is_runtime_profile_membership_domain_error(message: &str) -> bool { | "当前套餐已生效,无需重复购买" | "当前会员有效期内不支持月付 / 年付转换,到期后可重新选择" | "会员已到期,请按新购价格重新下单" + | "会员状态已变化,请重新下单" ) } 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 17811db29..737d1ff0c 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -9867,6 +9867,14 @@ fn apply_profile_membership_purchase( if settlement.change_kind == RuntimeProfileMembershipChangeKind::Upgrade { if let Some(row) = active_row { + // 中文注释:结算前比对订单冻结的变更前状态。若用户已先付了更高档位的升级单、 + // 或到期后按不同周期重新购买,旧升级单直接落库会把档位 / 周期覆盖回去, + // 只剩原到期日,产生降档或「年付 + 一个月到期」的矛盾行,因此拒绝并要求重新下单。 + if settlement.plan_before != Some(row.plan) + || settlement.cycle_kind_before != Some(row.cycle_kind) + { + return Err("会员状态已变化,请重新下单".to_string()); + } return apply_profile_membership_upgrade( ctx, user_id, From 4c6af9f190e500868d9746c6bbbf65ce060c1154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 16:18:14 +0800 Subject: [PATCH 081/232] =?UTF-8?q?=E4=BC=9A=E5=91=98=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E4=B8=8B=E5=8F=91=E6=8E=A8=E8=8D=90=E6=A1=A3?= =?UTF-8?q?=E4=BD=8D=E4=B8=8E=E4=B8=8D=E8=AE=BE=E5=B9=B6=E5=8F=91=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E6=A0=87=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProfileMembershipPlanResponse 新增 recommended 与 unlimitedConcurrency 两个派生布尔 module-runtime 新增 PROFILE_MEMBERSHIP_RECOMMENDED_PLAN 常量并导出,作为推荐档位唯一真相源 api-server 目录响应按该常量与 is_unlimited_concurrency 派生标志 前端 ProfileMembershipPlanRecord 契约补上两个字段 membershipCopy 删除硬编码的 'plus' 与 128,并发文案改读 unlimitedConcurrency 充值弹窗推荐标签改读 plan.recommended 更新充值弹窗测试夹具 --- .../index.test.tsx | 4 +++ .../PlatformProfileRechargeModal/index.tsx | 9 ++--- .../membershipCopy.ts | 34 +++++++++---------- packages/shared/src/contracts/runtime.ts | 4 +++ .../crates/api-server/src/runtime_profile.rs | 12 ++++--- server-rs/crates/module-runtime/src/lib.rs | 16 ++++----- .../module-runtime/src/membership/catalog.rs | 5 +++ .../crates/shared-contracts/src/runtime.rs | 6 ++++ 8 files changed, 55 insertions(+), 35 deletions(-) diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx index 3855a4fcd..58875c494 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx @@ -96,6 +96,8 @@ const STARTER_PLAN = { periodPoints: 400, modelAccess: 'basic' as const, concurrentJobLimit: 1, + recommended: false, + unlimitedConcurrency: false, enabled: true, }; @@ -109,6 +111,7 @@ const PLUS_PLAN = { periodPoints: 1_150, modelAccess: 'full' as const, concurrentJobLimit: 3, + recommended: true, }; const MAX_PLAN = { @@ -121,6 +124,7 @@ const MAX_PLAN = { periodPoints: 8_650, modelAccess: 'full' as const, concurrentJobLimit: 128, + unlimitedConcurrency: true, }; vi.mock('qrcode', () => ({ diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index 07989fff9..2b02f5c57 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -19,7 +19,6 @@ import { resolveProfileMudPointDisplayTotal, } from '../../utils/mudPoints'; import { - RECOMMENDED_MEMBERSHIP_PLAN, resolveMembershipConcurrencyLabel, resolveMembershipConcurrencyShortLabel, resolveMembershipModelAccessLabel, @@ -673,7 +672,7 @@ function PlatformMembershipPlanCard({
  • {resolveMembershipModelAccessLabel(plan.modelAccess)}
  • -
  • {resolveMembershipConcurrencyLabel(plan.concurrentJobLimit)}
  • +
  • {resolveMembershipConcurrencyLabel(plan)}
- resolveMembershipConcurrencyShortLabel(plan.concurrentJobLimit), - ), + values: plans.map((plan) => resolveMembershipConcurrencyShortLabel(plan)), }, ]; @@ -1380,7 +1377,7 @@ export function PlatformProfileRechargeModal({ plan={plan} cycleKind={cycleKind} isCurrentPlan={plan.plan === membership?.plan} - recommended={plan.plan === RECOMMENDED_MEMBERSHIP_PLAN} + recommended={plan.recommended} actionLabel={action.label} disabled={action.disabled} submitting={planSubmitting} diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/membershipCopy.ts b/packages/shared/src/components/PlatformProfileRechargeModal/membershipCopy.ts index 87bb554ab..ff8132278 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/membershipCopy.ts +++ b/packages/shared/src/components/PlatformProfileRechargeModal/membershipCopy.ts @@ -1,18 +1,16 @@ import type { ProfileMembershipModelAccess, ProfileMembershipPlan, + ProfileMembershipPlanRecord, } from '../../contracts/runtime'; /** * 会员侧的纯展示文案。 * - * 价格、每期泥点、模型权限、并发上限、标题的唯一真相源是后端 `profile_membership_plan` 目录, - * 这里只放后端没有、也不该由后端承担的市场表达(数量估算、推荐标签、省费口径)。 + * 价格、每期泥点、模型权限、并发上限、标题、推荐档位与「是否不设并发上限」的唯一真相源 + * 都是后端 `profile_membership_plan` 目录响应;这里只放后端没有、也不该由后端承担的 + * 市场表达(数量估算、省费口径)。 */ -export const RECOMMENDED_MEMBERSHIP_PLAN: ProfileMembershipPlan = 'plus'; - -/** 并发上限哨兵值:与后端 `MEMBERSHIP_UNLIMITED_CONCURRENCY` 对齐,`>= 128` 视为不设上限。 */ -export const UNLIMITED_CONCURRENT_JOB_LIMIT = 128; type MembershipPlanCopy = { /** 每月预计可完成的数量估算。 */ @@ -66,24 +64,26 @@ export function resolveMembershipModelAccessLabel( return modelAccess === 'full' ? '基础及高性能模型' : '基础模型'; } -/** 套餐卡里的并发文案。 */ +/** 套餐卡里的并发文案;是否不设上限由后端 `unlimitedConcurrency` 标志决定。 */ export function resolveMembershipConcurrencyLabel( - concurrentJobLimit: number, + plan: Pick< + ProfileMembershipPlanRecord, + 'concurrentJobLimit' | 'unlimitedConcurrency' + >, ): string { - return concurrentJobLimit >= UNLIMITED_CONCURRENT_JOB_LIMIT + return plan.unlimitedConcurrency ? '独立任务并发不设套餐上限' - : `同时运行 ${concurrentJobLimit} 个独立任务`; + : `同时运行 ${plan.concurrentJobLimit} 个独立任务`; } /** 对比表里的并发短文案。 */ export function resolveMembershipConcurrencyShortLabel( - concurrentJobLimit: number, + plan: Pick< + ProfileMembershipPlanRecord, + 'concurrentJobLimit' | 'unlimitedConcurrency' + >, ): string { - return isUnlimitedConcurrency(concurrentJobLimit) + return plan.unlimitedConcurrency ? '不设套餐上限' - : `${concurrentJobLimit} 个`; -} - -export function isUnlimitedConcurrency(concurrentJobLimit: number): boolean { - return concurrentJobLimit >= UNLIMITED_CONCURRENT_JOB_LIMIT; + : `${plan.concurrentJobLimit} 个`; } diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 4abb20f2b..3638f075a 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -163,6 +163,10 @@ export type ProfileMembershipPlanRecord = { modelAccess: ProfileMembershipModelAccess; /** 并发上限;`128` 表示不设上限。 */ concurrentJobLimit: number; + /** 是否为运营推荐档位;由后端目录给出,前端不再硬编码档位 token。 */ + recommended: boolean; + /** 是否不设并发上限;由后端按哨兵值派生,前端不再硬编码 `128`。 */ + unlimitedConcurrency: boolean; enabled: boolean; }; diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 672c5da4f..3627ab371 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -9,8 +9,9 @@ use axum::{ }; use hmac::{Hmac, Mac}; use module_runtime::{ - AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, - PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI, + AnalyticsGranularity, PROFILE_MEMBERSHIP_RECOMMENDED_PLAN, + PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileCodeOperationRecord, @@ -24,7 +25,7 @@ use module_runtime::{ RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType, - RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind, + RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind, is_unlimited_concurrency, }; use platform_wechat::pay::{WechatPayError, WechatPayNotifyOrder}; use serde::{Deserialize, Serialize}; @@ -2061,7 +2062,8 @@ fn build_profile_recharge_product_response( } } -/// 会员档位目录 → 对外响应;`concurrent_job_limit` 的 `128` 哨兵直接透出,由前端展示为“不设上限”。 +/// 会员档位目录 → 对外响应。`recommended` 与 `unlimited_concurrency` 由后端派生, +/// 前端只消费标志,不再各自硬编码推荐档位或并发哨兵 `128`。 fn build_profile_membership_plan_response( record: RuntimeProfileMembershipPlanRecord, ) -> ProfileMembershipPlanResponse { @@ -2074,6 +2076,8 @@ fn build_profile_membership_plan_response( period_points: record.period_points, model_access: record.model_access.as_str().to_string(), concurrent_job_limit: record.concurrent_job_limit, + recommended: record.plan == PROFILE_MEMBERSHIP_RECOMMENDED_PLAN, + unlimited_concurrency: is_unlimited_concurrency(record.concurrent_job_limit), enabled: record.enabled, } } diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 801644791..b3e48fb39 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -21,14 +21,14 @@ pub use llm_billing::*; // 通配会把公历基元和目录内部件都变成公开 API,模块继续增长后容易命名冲突; // 这里只重导出真正对外的会员契约符号,其余留在 `membership::` / `civil_calendar::` 命名空间下。 pub use membership::{ - MEMBERSHIP_UNLIMITED_CONCURRENCY, RuntimeProfileMembershipPlanRecord, - RuntimeProfileMembershipPlanSnapshot, RuntimeProfileMembershipUpgradeQuoteInput, - beijing_local_micros, build_runtime_profile_membership_plan_snapshot, - check_expired_membership_upgrade_settlement, is_unlimited_concurrency, membership_cycle_window, - membership_expires_at, parse_runtime_profile_membership_product_id, - quote_runtime_profile_membership_upgrade, resolve_runtime_profile_membership_plan_row, - runtime_profile_membership_plan_catalog, runtime_profile_membership_plan_rank, - runtime_profile_membership_product_id, + MEMBERSHIP_UNLIMITED_CONCURRENCY, PROFILE_MEMBERSHIP_RECOMMENDED_PLAN, + RuntimeProfileMembershipPlanRecord, RuntimeProfileMembershipPlanSnapshot, + RuntimeProfileMembershipUpgradeQuoteInput, beijing_local_micros, + build_runtime_profile_membership_plan_snapshot, check_expired_membership_upgrade_settlement, + is_unlimited_concurrency, membership_cycle_window, membership_expires_at, + parse_runtime_profile_membership_product_id, quote_runtime_profile_membership_upgrade, + resolve_runtime_profile_membership_plan_row, runtime_profile_membership_plan_catalog, + runtime_profile_membership_plan_rank, runtime_profile_membership_product_id, validate_runtime_profile_membership_plan_row_against_catalog, }; diff --git a/server-rs/crates/module-runtime/src/membership/catalog.rs b/server-rs/crates/module-runtime/src/membership/catalog.rs index f6c1962fd..b907dc49e 100644 --- a/server-rs/crates/module-runtime/src/membership/catalog.rs +++ b/server-rs/crates/module-runtime/src/membership/catalog.rs @@ -24,6 +24,11 @@ use crate::{ RuntimeProfileMembershipModelAccess, RuntimeProfileMembershipPlan, }; +/// 运营推荐档位。推荐是后端单一真相源:目录响应按此给出 `recommended` 标志, +/// 前端只消费标志,不再各自硬编码 `'plus'`。 +pub const PROFILE_MEMBERSHIP_RECOMMENDED_PLAN: RuntimeProfileMembershipPlan = + RuntimeProfileMembershipPlan::Plus; + /// 目录一行:一个档位的可配置价格与权益。 #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 55c074d1a..7bf133256 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -274,6 +274,10 @@ pub struct ProfileMembershipPlanResponse { pub model_access: String, /// 并发上限;`128` 表示不设上限。 pub concurrent_job_limit: u32, + /// 是否为运营推荐档位;由后端目录决定,前端不再硬编码档位 token。 + pub recommended: bool, + /// 是否不设并发上限;由后端按哨兵值派生,前端不再硬编码 `128`。 + pub unlimited_concurrency: bool, pub enabled: bool, } @@ -1699,6 +1703,8 @@ mod tests { period_points: 1_150, model_access: "full".to_string(), concurrent_job_limit: 3, + recommended: true, + unlimited_concurrency: false, enabled: true, }], latest_order: None, From 6f0d773ddeec360fd85bce0fe4ff62a11919a77f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 16:27:04 +0800 Subject: [PATCH 082/232] =?UTF-8?q?=E4=BC=9A=E5=91=98=E6=A1=A3=E4=BD=8D?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E7=A7=8D=E5=AD=90=E6=94=B9=E7=94=B1=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=88=9D=E5=A7=8B=E5=8C=96=E6=92=AD=E7=A7=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 init 生命周期 reducer,数据库首次创建时把 profile_membership_plan 整表播种 membership_plan_row 与 membership_plan_records 去掉懒播种,只读路径不再写库 ensure_default_profile_recharge_product_config 不再顺带初始化会员目录 存量数据库此前已由懒播种写入目录行,新建数据库由 init reducer 播种 --- .../spacetime-module/src/runtime/active/profile.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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 737d1ff0c..48d64193c 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -8812,8 +8812,16 @@ fn ensure_default_profile_membership_plan(ctx: &ReducerContext) { } } -fn ensure_default_profile_recharge_product_config(ctx: &ReducerContext) { +/// 模块初始化生命周期:只在数据库首次创建时把会员档位目录整表播种。 +/// +/// 迁移目的:报价 / 充值中心 / 后台查询等只读入口不再调用 `ensure_default_profile_membership_plan` +/// 写库;存量数据库此前已由懒播种写入了目录行,新建数据库则由本 reducer 播种。 +#[spacetimedb::reducer(init)] +pub fn init_profile_membership_plan_catalog(ctx: &ReducerContext) { ensure_default_profile_membership_plan(ctx); +} + +fn ensure_default_profile_recharge_product_config(ctx: &ReducerContext) { if ctx.db.profile_recharge_product_config().count() > 0 { migrate_legacy_default_profile_recharge_product_config(ctx); return; @@ -9146,7 +9154,6 @@ fn membership_plan_row( ctx: &ReducerContext, plan: RuntimeProfileMembershipPlan, ) -> Option { - ensure_default_profile_membership_plan(ctx); ctx.db.profile_membership_plan().plan().find(&plan) } @@ -9186,9 +9193,8 @@ fn membership_plan_snapshot( }) } -/// 充值中心与后台共用的目录行读取,按 `rank` 升序。 +/// 充值中心与后台共用的目录行读取,按 `rank` 升序;只读,不再触发表为空时的懒播种。 fn membership_plan_records(ctx: &ReducerContext) -> Vec { - ensure_default_profile_membership_plan(ctx); let mut rows = ctx.db.profile_membership_plan().iter().collect::>(); rows.sort_by(|left, right| { left.rank From 43f0c51120f456948a8d0e3d5cfd1b0bd9fe9e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 16:42:48 +0800 Subject: [PATCH 083/232] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E6=94=B9=E8=B5=B0=E5=8F=AA=E8=AF=BB=E4=BC=9A?= =?UTF-8?q?=E5=91=98=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增只读会员快照 procedure get_profile_membership_snapshot_and_return,不刷新钱包 / 账期、不写库 module-runtime 新增 RuntimeProfileMembershipGetInput 与 RuntimeProfileMembershipSnapshotProcedureResult 及入参构造器 spacetime-client 增加 get_profile_membership_snapshot facade、From 转换与结果映射,并同步生成绑定 build_profile_membership_snapshot 拆成写库与纯读两条路径,共用 from_row 投影 admin_get_user_detail 改用只读会员快照,打开详情不再改用户钱包与会员账期 --- .../crates/api-server/src/admin_recharge.rs | 5 +- .../crates/module-runtime/src/commands.rs | 7 +++ server-rs/crates/module-runtime/src/domain.rs | 16 +++++ .../crates/spacetime-client/src/active.rs | 8 ++- .../src/active/mapper/runtime_profile.rs | 22 +++++++ .../spacetime-client/src/active/runtime.rs | 28 +++++++++ .../spacetime-client/src/module_bindings.rs | 6 ++ ...embership_snapshot_and_return_procedure.rs | 59 +++++++++++++++++++ ...ntime_profile_membership_get_input_type.rs | 15 +++++ ...mbership_snapshot_procedure_result_type.rs | 19 ++++++ .../src/runtime/active/profile.rs | 54 ++++++++++++++++- 11 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_profile_membership_snapshot_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_snapshot_procedure_result_type.rs diff --git a/server-rs/crates/api-server/src/admin_recharge.rs b/server-rs/crates/api-server/src/admin_recharge.rs index 88b30e8f2..1d54a189f 100644 --- a/server-rs/crates/api-server/src/admin_recharge.rs +++ b/server-rs/crates/api-server/src/admin_recharge.rs @@ -162,10 +162,9 @@ pub async fn admin_get_user_detail( fetch_admin_user_cumulative_recharged_cents(&state, &user.id).await; let membership_record = state .spacetime_client() - .get_profile_recharge_center(user.id.clone()) + .get_profile_membership_snapshot(user.id.clone()) .await - .map_err(|error| spacetime_error_response(&request_context, error))? - .membership; + .map_err(|error| spacetime_error_response(&request_context, error))?; Ok(json_success_body( Some(&request_context), diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 702bc8398..31e93298f 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -345,6 +345,13 @@ pub fn build_runtime_profile_recharge_center_get_input( Ok(RuntimeProfileRechargeCenterGetInput { user_id }) } +pub fn build_runtime_profile_membership_get_input( + user_id: String, +) -> Result { + let user_id = normalize_runtime_profile_user_id(user_id)?; + Ok(RuntimeProfileMembershipGetInput { user_id }) +} + pub fn build_runtime_profile_recharge_order_get_input( order_id: String, ) -> Result { diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 3fd46f4ac..29e195f25 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1532,6 +1532,22 @@ pub struct RuntimeProfileMembershipSnapshot { pub cycle_kind: RuntimeProfileMembershipCycleKind, } +/// 只读会员快照的入参;后台详情等只读入口使用。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipGetInput { + pub user_id: String, +} + +/// 只读会员快照 procedure 的结果:成功时 `record` 为会员投影,失败时给出 `error_message`。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipSnapshotProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileRechargeOrderSnapshot { diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index 61f50ea68..83526c14b 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -72,8 +72,9 @@ use module_runtime::{ AnalyticsMetricQueryResponse as DomainAnalyticsMetricQueryResponse, RuntimeProfileDashboardRecord, RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeAdminListRecord, RuntimeProfileInviteCodeRecord, - RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, - RuntimeProfileRechargeProductConfigRecord, RuntimeProfileRedeemCodeAdminListRecord, + RuntimeProfileMembershipRecord, RuntimeProfileRechargeCenterRecord, + RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductConfigRecord, + RuntimeProfileRedeemCodeAdminListRecord, RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, @@ -87,9 +88,10 @@ use module_runtime::{ build_runtime_profile_feedback_submission_record, build_runtime_profile_invite_code_admin_list_input, build_runtime_profile_invite_code_admin_upsert_input, build_runtime_profile_invite_code_record, + build_runtime_profile_membership_get_input, build_runtime_profile_membership_plan_admin_list_input, build_runtime_profile_membership_plan_admin_upsert_input, - build_runtime_profile_membership_upgrade_quote_input, + build_runtime_profile_membership_record, build_runtime_profile_membership_upgrade_quote_input, build_runtime_profile_membership_upgrade_quote_record, build_runtime_profile_recharge_center_get_input, build_runtime_profile_recharge_center_record, build_runtime_profile_recharge_order_close_input, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs index c65d6ab68..591dd1209 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs @@ -18,6 +18,14 @@ impl From } } +impl From for RuntimeProfileMembershipGetInput { + fn from(input: module_runtime::RuntimeProfileMembershipGetInput) -> Self { + Self { + user_id: input.user_id, + } + } +} + pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back( value: crate::module_bindings::RuntimeProfileWalletLedgerSourceType, ) -> module_runtime::RuntimeProfileWalletLedgerSourceType { @@ -740,6 +748,20 @@ pub(crate) fn map_runtime_profile_recharge_center_procedure_result( )) } +pub(crate) fn map_runtime_profile_membership_snapshot_procedure_result( + result: RuntimeProfileMembershipSnapshotProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile membership 快照"))?; + Ok(build_runtime_profile_membership_record( + map_runtime_profile_membership_snapshot(snapshot), + )) +} + pub(crate) fn map_runtime_profile_recharge_order_procedure_result( result: RuntimeProfileRechargeCenterProcedureResult, ) -> Result< diff --git a/server-rs/crates/spacetime-client/src/active/runtime.rs b/server-rs/crates/spacetime-client/src/active/runtime.rs index 7c5c4bb34..99e3f4bf6 100644 --- a/server-rs/crates/spacetime-client/src/active/runtime.rs +++ b/server-rs/crates/spacetime-client/src/active/runtime.rs @@ -394,6 +394,34 @@ impl SpacetimeClient { .await } + /// 只读会员快照:不刷新钱包 / 账期,供后台详情等只读入口使用。 + pub async fn get_profile_membership_snapshot( + &self, + user_id: String, + ) -> Result { + let procedure_input = build_runtime_profile_membership_get_input(user_id) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect( + "get_profile_membership_snapshot_and_return", + move |connection, sender| { + connection + .procedures() + .get_profile_membership_snapshot_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_membership_snapshot_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn create_profile_recharge_order( &self, user_id: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index d19e12c93..2bfd173fe 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -478,6 +478,7 @@ pub mod get_game_distribution_version_and_return_procedure; pub mod get_llm_router_account_and_return_procedure; pub mod get_owner_game_distribution_version_and_return_procedure; pub mod get_profile_dashboard_procedure; +pub mod get_profile_membership_snapshot_and_return_procedure; pub mod get_profile_membership_upgrade_quote_procedure; pub mod get_profile_recharge_center_procedure; pub mod get_profile_recharge_order_and_return_procedure; @@ -653,6 +654,7 @@ pub mod runtime_profile_invite_code_admin_upsert_input_type; pub mod runtime_profile_invite_code_snapshot_type; pub mod runtime_profile_membership_change_kind_type; pub mod runtime_profile_membership_cycle_kind_type; +pub mod runtime_profile_membership_get_input_type; pub mod runtime_profile_membership_model_access_type; pub mod runtime_profile_membership_order_change_snapshot_type; pub mod runtime_profile_membership_plan_admin_list_input_type; @@ -661,6 +663,7 @@ pub mod runtime_profile_membership_plan_admin_procedure_result_type; pub mod runtime_profile_membership_plan_admin_upsert_input_type; pub mod runtime_profile_membership_plan_record_type; pub mod runtime_profile_membership_plan_type; +pub mod runtime_profile_membership_snapshot_procedure_result_type; pub mod runtime_profile_membership_snapshot_type; pub mod runtime_profile_membership_status_type; pub mod runtime_profile_membership_upgrade_quote_get_input_type; @@ -1295,6 +1298,7 @@ pub use get_game_distribution_version_and_return_procedure::get_game_distributio pub use get_llm_router_account_and_return_procedure::get_llm_router_account_and_return; pub use get_owner_game_distribution_version_and_return_procedure::get_owner_game_distribution_version_and_return; pub use get_profile_dashboard_procedure::get_profile_dashboard; +pub use get_profile_membership_snapshot_and_return_procedure::get_profile_membership_snapshot_and_return; pub use get_profile_membership_upgrade_quote_procedure::get_profile_membership_upgrade_quote; pub use get_profile_recharge_center_procedure::get_profile_recharge_center; pub use get_profile_recharge_order_and_return_procedure::get_profile_recharge_order_and_return; @@ -1470,6 +1474,7 @@ pub use runtime_profile_invite_code_admin_upsert_input_type::RuntimeProfileInvit pub use runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot; pub use runtime_profile_membership_change_kind_type::RuntimeProfileMembershipChangeKind; pub use runtime_profile_membership_cycle_kind_type::RuntimeProfileMembershipCycleKind; +pub use runtime_profile_membership_get_input_type::RuntimeProfileMembershipGetInput; pub use runtime_profile_membership_model_access_type::RuntimeProfileMembershipModelAccess; pub use runtime_profile_membership_order_change_snapshot_type::RuntimeProfileMembershipOrderChangeSnapshot; pub use runtime_profile_membership_plan_admin_list_input_type::RuntimeProfileMembershipPlanAdminListInput; @@ -1478,6 +1483,7 @@ pub use runtime_profile_membership_plan_admin_procedure_result_type::RuntimeProf pub use runtime_profile_membership_plan_admin_upsert_input_type::RuntimeProfileMembershipPlanAdminUpsertInput; pub use runtime_profile_membership_plan_record_type::RuntimeProfileMembershipPlanRecord; pub use runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; +pub use runtime_profile_membership_snapshot_procedure_result_type::RuntimeProfileMembershipSnapshotProcedureResult; pub use runtime_profile_membership_snapshot_type::RuntimeProfileMembershipSnapshot; pub use runtime_profile_membership_status_type::RuntimeProfileMembershipStatus; pub use runtime_profile_membership_upgrade_quote_get_input_type::RuntimeProfileMembershipUpgradeQuoteGetInput; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_membership_snapshot_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_membership_snapshot_and_return_procedure.rs new file mode 100644 index 000000000..0ea44fe05 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_membership_snapshot_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_membership_get_input_type::RuntimeProfileMembershipGetInput; +use super::runtime_profile_membership_snapshot_procedure_result_type::RuntimeProfileMembershipSnapshotProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetProfileMembershipSnapshotAndReturnArgs { + pub input: RuntimeProfileMembershipGetInput, +} + +impl __sdk::InModule for GetProfileMembershipSnapshotAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_profile_membership_snapshot_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_profile_membership_snapshot_and_return { + fn get_profile_membership_snapshot_and_return(&self, input: RuntimeProfileMembershipGetInput) { + self.get_profile_membership_snapshot_and_return_then(input, |_, _| {}); + } + + fn get_profile_membership_snapshot_and_return_then( + &self, + input: RuntimeProfileMembershipGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_profile_membership_snapshot_and_return for super::RemoteProcedures { + fn get_profile_membership_snapshot_and_return_then( + &self, + input: RuntimeProfileMembershipGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileMembershipSnapshotProcedureResult>( + "get_profile_membership_snapshot_and_return", + GetProfileMembershipSnapshotAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_get_input_type.rs new file mode 100644 index 000000000..3e1dbd0a8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_get_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipGetInput { + pub user_id: String, +} + +impl __sdk::InModule for RuntimeProfileMembershipGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_snapshot_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_snapshot_procedure_result_type.rs new file mode 100644 index 000000000..6c9abd06a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_snapshot_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_membership_snapshot_type::RuntimeProfileMembershipSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipSnapshotProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileMembershipSnapshotProcedureResult { + type Module = super::RemoteModule; +} 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 48d64193c..869fc7af6 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -1619,6 +1619,37 @@ pub fn get_profile_recharge_center( } } +/// 只读会员快照:不刷新钱包 / 账期、不写库,供后台详情等只读入口使用。 +#[spacetimedb::procedure] +pub fn get_profile_membership_snapshot_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileMembershipGetInput, +) -> RuntimeProfileMembershipSnapshotProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + let validated = build_runtime_profile_membership_get_input(input.user_id.clone()) + .map_err(|error| error.to_string())?; + Ok(build_profile_membership_snapshot_readonly( + tx, + &validated.user_id, + )) + }) { + Ok(record) => RuntimeProfileMembershipSnapshotProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileMembershipSnapshotProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn get_profile_recharge_order_and_return( ctx: &mut ProcedureContext, @@ -9764,17 +9795,38 @@ fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Ti ); } +/// 会写库的会员快照:先刷新钱包 / 账期(可能换期、补发每日免费额度),再按库内投影读取。 fn build_profile_membership_snapshot( ctx: &ReducerContext, user_id: &str, ) -> RuntimeProfileMembershipSnapshot { refresh_profile_wallet_expiring_points(ctx, user_id, ctx.timestamp); + build_profile_membership_snapshot_readonly(ctx, user_id) +} + +/// 纯读会员快照:用只读账期投影替代写库刷新,不落任何表,供后台详情等只读入口使用。 +/// +/// 与写库刷新后的对外语义一致:未过期按当前账期投影,已过期按非会员返回;区别只是不触发换期与补发。 +fn build_profile_membership_snapshot_readonly( + ctx: &ReducerContext, + user_id: &str, +) -> RuntimeProfileMembershipSnapshot { let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); let membership = ctx .db .profile_membership() .user_id() - .find(&user_id.to_string()); + .find(&user_id.to_string()) + .map(|row| project_profile_membership_cycle_at(ctx, row, ctx.timestamp)); + build_profile_membership_snapshot_from_row(user_id, membership, now_micros) +} + +/// 把会员行(`None` 表示从未开通)映射为对外快照,是写库与纯读两条路径共用的投影。 +fn build_profile_membership_snapshot_from_row( + user_id: &str, + membership: Option, + now_micros: i64, +) -> RuntimeProfileMembershipSnapshot { match membership { Some(row) if row.expires_at.to_micros_since_unix_epoch() > now_micros => { RuntimeProfileMembershipSnapshot { From 1a106eadef3bf80ab0ccf7dd9da2d6c1bf9b31b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 16:48:12 +0800 Subject: [PATCH 084/232] =?UTF-8?q?=E6=AF=8F=E6=97=A5=E5=85=8D=E8=B4=B9?= =?UTF-8?q?=E5=8F=91=E6=94=BE=E9=A2=9D=E4=B8=BA=200=20=E6=97=B6=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E9=9A=90=E8=97=8F=E8=AF=A5=E6=B1=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:module-runtime 的 daily_free_points_per_day 校验由「必须大于 0」放宽为只校验上界,错误变体改名 DailyFreePointsPerDayOverflow - 前端共享层:mudPoints 新增 shouldShowProfileDailyFreePool,判定 remaining > 0 || reset > 0,判定收口一处 - 钱包入口:PlatformMudPointWalletEntry 在每日免费发放额为 0 且无存量时隐藏该行 - 充值弹层:PlatformProfileRechargeModal 池概览按可见池渲染(三列变两列),扣点顺序提示与泥点确认页文案随可见性切换 - Admin Web:账号配置页每日免费泥点允许填 0(校验、min 与文案同步) - 测试:删除「每日免费必然存在」断言,新增隐藏 / 有存量仍显示 / 发放额为正三类用例,并修正钱包入口测试在 HEAD 上失效的选择器 - 文档:更新会员与泥点前端改造设计文档 §14 与 shared-memory 决策记录 --- .../AdminProfileWalletConfigPage.test.tsx | 35 +++++- .../pages/AdminProfileWalletConfigPage.tsx | 18 ++- .../shared-memory/decision-log.md | 10 ++ ...�技术设计】会员与泥点前端改造-2026-10-03.md | 40 +++++++ .../index.test.tsx | 108 ++++++++++++++---- .../PlatformMudPointWalletEntry/index.tsx | 13 ++- .../index.test.tsx | 70 ++++++++++++ .../PlatformProfileRechargeModal/index.tsx | 36 ++++-- packages/shared/src/utils/mudPoints.ts | 23 ++++ .../crates/module-runtime/src/commands.rs | 4 +- server-rs/crates/module-runtime/src/errors.rs | 6 +- server-rs/crates/module-runtime/src/lib.rs | 14 +-- .../ImageCanvasEditorView.test.tsx | 3 +- .../ImageCanvasTopbarView.test.tsx | 1 - 14 files changed, 326 insertions(+), 55 deletions(-) diff --git a/apps/admin-web/src/pages/AdminProfileWalletConfigPage.test.tsx b/apps/admin-web/src/pages/AdminProfileWalletConfigPage.test.tsx index 1697c489a..4a95de136 100644 --- a/apps/admin-web/src/pages/AdminProfileWalletConfigPage.test.tsx +++ b/apps/admin-web/src/pages/AdminProfileWalletConfigPage.test.tsx @@ -94,7 +94,7 @@ test('账号配置页一次保存初始和每日免费泥点', async () => { ); }); -test('账号配置页拒绝非正整数每日免费额度', async () => { +test('账号配置页拒绝负数或非整数每日免费额度', async () => { render( { (screen.getByRole('button', { name: '保存' }) as HTMLButtonElement) .disabled, ).toBe(true); + fireEvent.change(input, { target: { value: '-1' } }); + expect( + (screen.getByRole('button', { name: '保存' }) as HTMLButtonElement) + .disabled, + ).toBe(true); expect(upsertProfileWalletConfig).not.toHaveBeenCalled(); }); + +test('账号配置页允许把每日免费泥点配置为 0', async () => { + const user = userEvent.setup(); + vi.mocked(upsertProfileWalletConfig).mockResolvedValue({ + ...configResponse, + dailyFreePointsPerDay: 0, + }); + render( + , + ); + fireEvent.change(await screen.findByLabelText('每日免费泥点数'), { + target: { value: '0' }, + }); + await user.click(screen.getByRole('button', { name: '保存' })); + expect(screen.getByText('初始 100 泥点,每日免费 0 泥点')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => + expect(upsertProfileWalletConfig).toHaveBeenCalledWith('admin-token', { + initialMudPoints: 100, + dailyFreePointsPerDay: 0, + }), + ); +}); diff --git a/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx b/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx index 9b3b4ea08..434451913 100644 --- a/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx +++ b/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx @@ -66,11 +66,11 @@ export function AdminProfileWalletConfigPage({ if (isSaving) { return; } - const normalizedDailyFreePointsPerDay = parsePositiveInteger( + const normalizedDailyFreePointsPerDay = parseNonNegativeInteger( dailyFreePointsPerDay, ); - if (!normalizedDailyFreePointsPerDay) { - setErrorMessage('每日免费泥点数必须是大于 0 的整数'); + if (normalizedDailyFreePointsPerDay === null) { + setErrorMessage('每日免费泥点数必须是不小于 0 的整数'); return; } @@ -139,7 +139,7 @@ export function AdminProfileWalletConfigPage({ @@ -205,3 +205,11 @@ function parsePositiveInteger(value: string) { const parsed = Number(value); return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0; } + +function parseNonNegativeInteger(value: string): number | null { + if (value.trim() === '') { + return null; + } + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null; +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 465ae9866..f970f10c4 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,4 +1,14 @@ # 决策记录 + +## 2026-10-03 每日免费发放额为 0 时前端隐藏该池 + +- 背景:运营需要一个可逆的「不提供每日免费泥点」状态。不给它新增 `retired` 状态位或新字段,直接把后台配置 `daily_free_points_per_day` 配成 0,让「每日免费发放额」这个普通数值自己表达;前端据此隐藏每日免费相关入口。 +- 决策(信号取 `dailyFreeResetPoints` 而非剩余):`ProfileMudPointBalance.dailyFreeResetPoints <= 0` 表示不提供该池。`dailyFreePoints`(当天剩余)为 0 是每天用完后的正常状态,不能当信号。 +- 决策(显示规则 `remaining > 0 || reset > 0`):当天仍有存量(配置中途改成 0,但当天已发未用完)时必须继续展示——扣点顺序不变,这部分存量会被优先扣减,隐藏后用户会「有余额看不见、还被先扣」。判定收口到 `packages/shared/src/utils/mudPoints.ts` 的 `shouldShowProfileDailyFreePool`,三端共享组件共用,消费方 controller 不改。 +- 决策(后端放开校验、不新增契约字段):`server-rs/crates/module-runtime/src/commands.rs` 的 `daily_free_points_per_day` 由「必须 > 0」放宽为 `0..=i64::MAX`,错误变体改名为 `DailyFreePointsPerDayOverflow`;`0` 本就是 `u64` 合法值,`shared-contracts` / ts-rs 生成物 / OpenAPI 不改。发放与账本逻辑不变(`amount_delta == 0` 时后端本就跳过写账)。 +- 影响范围:`packages/shared/src/utils/mudPoints.ts`、`PlatformMudPointWalletEntry`、`PlatformProfileRechargeModal`(池概览 3 列变 2 列、扣点顺序提示与泥点确认页文案随可见性切换)、`apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx`(每日免费允许填 0)、`server-rs/crates/module-runtime/{commands,errors,lib}.rs`;`daily_free_grant` / `daily_free_reset` 账本文案保留,历史流水不改写。 +- 验证:`cargo check -p module-runtime`、`cargo test -p module-runtime profile_wallet_config_allows_zero_daily_free_points`、`cargo fmt --check`、根 `npm run typecheck`、`npm run admin-web:typecheck`、共享层与 admin 定向 vitest 22/22、`npm run check:encoding`、`git diff --check` 通过。 + ## 2026-10-02 资源编辑远端失败的原始原因穿出到工具错误,资源编辑错误通道补一层 typed - 背景:轮询到 `status=failed` 时客户端只读 `status`,丢掉平台在同一个响应里给的 `error`(契约 `ExternalEditorGenerationJobResponse.error`),统一写 `terminal_failure_code = remote-generation-failed` 并返回「remote-terminal-failed: 资源编辑生成失败」。平台的可行动原因就此消失:模型与用户卡片只看到一句「失败了」,重试路径(`ensure_resource_edit_phase_resumable`)也只有分类码。这违反 `pitfalls.md`「远端资源编辑终态必须指出唯一出口」里已写下的口径——「首次失败的原始拒绝说明继续由当次错误文案承担」;提交期 HTTP 400 分支(`editor_api_rejection_reason`)兑现了,轮询分支没有。另外 `remote-terminal-failed:` 只是文案前缀(全仓没有 `starts_with` 解析它),在第一句失败文案里与「失败」重复。 diff --git a/docs/technical/【技术设计】会员与泥点前端改造-2026-10-03.md b/docs/technical/【技术设计】会员与泥点前端改造-2026-10-03.md index a36f87b32..88a963eb5 100644 --- a/docs/technical/【技术设计】会员与泥点前端改造-2026-10-03.md +++ b/docs/technical/【技术设计】会员与泥点前端改造-2026-10-03.md @@ -300,3 +300,43 @@ onConfirmMembershipPayment: () => void; ### 13.3 既有问题(非本次引入) - `ImageCanvasEditorView.test.tsx > shows the breakdown read failure without inventing rows` 在改动前的基线同样失败(已用 `git stash` 对照验证):弹层打开时仍处于 loading 分支,断言未等待错误态。属既有时序问题,本次未修改该行为。 + +--- + +## 14. 追加变更:每日免费发放额为 0 时隐藏(2026-10-03) + +### 14.1 语义 + +- **不引入** `retired` / 状态位 / 新字段;每日免费只是一个普通数值,发放额可以是 0。 +- **唯一信号**:`ProfileMudPointBalance.dailyFreeResetPoints <= 0`(后端每日发放额,来自后台配置 `daily_free_points_per_day`)。 +- **可逆**:后台把配置改回 > 0,前端下次读取自动恢复展示,无需额外开关。 +- **契约不变**:`ProfileMudPointBalanceResponse` 字段仍是必填 `u64`,`0` 本就合法;不改 `shared-contracts`、ts-rs 生成物与 OpenAPI。 +- **扣减与账本逻辑不变**:每日免费为 0 时不再产生发放账本(`profile.rs` 在 `amount_delta == 0` 时直接返回),创作扣点顺序仍为每日免费 → 月度 → 永久。 + +### 14.2 显示规则 + +``` +showDailyFreePool = dailyFreePoints > 0 || dailyFreeResetPoints > 0 +``` + +| 场景 | dailyFreePoints | dailyFreeResetPoints | 表现 | +|---|---|---|---| +| 当天正常用完 | 0 | 20 | 显示("每天重置为 20") | +| 每日免费发放额为 0 | 0 | 0 | 隐藏 | +| 配置中途改为 0,当天仍有存量 | > 0 | 0 | 显示(存量仍会被优先扣减,不能隐身) | + +判定收敛到 `packages/shared/src/utils/mudPoints.ts` 的纯函数,三端共享组件共用,消费方 controller 不改。 + +### 14.3 改动点 + +- 后端 `server-rs/crates/module-runtime/src/commands.rs`:`daily_free_points_per_day` 由"必须 > 0"放宽为 `0..=i64::MAX`;`errors.rs` 对应文案同步。 +- Admin Web `apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx`:每日免费泥点允许填 0。 +- 共享层 `packages/shared/src/utils/mudPoints.ts`:新增 `shouldShowProfileDailyFreePool`。 +- 共享层 `PlatformMudPointWalletEntry`:为 0 时隐藏"每日免费泥点"行。 +- 共享层 `PlatformProfileRechargeModal`:池概览按可见池动态渲染(三列变两列)、隐藏扣点顺序提示中与每日免费相关的口径。 +- 账本来源文案 `daily_free_grant` / `daily_free_reset` 保留,历史流水不改写。 + +### 14.4 测试 + +- 删除"每日免费必然存在"的断言(`PlatformMudPointWalletEntry`、`ImageCanvasTopbarView`、`ImageCanvasEditorView`),改为在共享层新增"发放额为 0 隐藏 / 有存量仍显示"的行为用例。 +- 后端与 Admin 追加"允许 0"的用例。 diff --git a/packages/shared/src/components/PlatformMudPointWalletEntry/index.test.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry/index.test.tsx index 982ddaeb6..7ead4be93 100644 --- a/packages/shared/src/components/PlatformMudPointWalletEntry/index.test.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry/index.test.tsx @@ -21,7 +21,7 @@ test('formats mud point counts consistently', () => { expect(formatMudPointCount(12_345, true)).toBe('1.2万'); }); -test('shows permanent, monthly and daily free points in the shared wallet panel', async () => { +test('shows permanent and monthly points in the shared wallet panel', async () => { const user = userEvent.setup(); const onRequestDetails = vi.fn(); const onRecharge = vi.fn(); @@ -37,10 +37,12 @@ test('shows permanent, monthly and daily free points in the shared wallet panel' />, ); - const balanceButton = screen.getByRole('button', { name: '泥点 207' }); + const balanceButton = screen.getByRole('button', { + name: /可用总额 207,月度/, + }); await user.hover(balanceButton); - const details = screen.getByRole('dialog', { name: '泥点账户详情' }); + const details = screen.getByRole('dialog', { name: '会员与泥点账户详情' }); expect(details.className).toContain('rounded-[1.25rem]'); expect(within(details).getByText('永久泥点')).toBeTruthy(); expect(within(details).getByText('月度泥点')).toBeTruthy(); @@ -51,18 +53,64 @@ test('shows permanent, monthly and daily free points in the shared wallet panel' within(details).getByText('2026-07-07 00:00 清零并发放新额度'), ).toBeTruthy(); expect(within(details).queryByText('限时泥点')).toBeNull(); - expect(within(details).getByText('每日免费泥点')).toBeTruthy(); - expect(within(details).getByText('27')).toBeTruthy(); - expect(within(details).getByText('每天重置为 20 泥点')).toBeTruthy(); expect(onRequestDetails).toHaveBeenCalledTimes(1); await user.click(within(details).getByRole('button', { name: '使用详情' })); expect(onOpenLedger).toHaveBeenCalledTimes(1); - await user.click(screen.getByRole('button', { name: '充值' })); + await user.click(screen.getByRole('button', { name: '会员与泥点' })); expect(onRecharge).toHaveBeenCalledTimes(1); }); +test('hides the daily free row when the daily grant is zero', async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.hover(screen.getByRole('button', { name: /可用总额 100,月度/ })); + + const details = screen.getByRole('dialog', { name: '会员与泥点账户详情' }); + expect(within(details).queryByText('每日免费泥点')).toBeNull(); + expect(within(details).getByText('永久泥点')).toBeTruthy(); + expect(within(details).getByText('月度泥点')).toBeTruthy(); +}); + +test('keeps the daily free row while the same-day remainder is still spendable', async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.hover(screen.getByRole('button', { name: /可用总额 115,月度/ })); + + const details = screen.getByRole('dialog', { name: '会员与泥点账户详情' }); + expect(within(details).getByText('每日免费泥点')).toBeTruthy(); + expect(within(details).getByText('15')).toBeTruthy(); +}); + test('shows the redeem-code entry directly in the wallet bar when provided', async () => { const user = userEvent.setup(); const onRedeemCode = vi.fn(); @@ -93,12 +141,18 @@ test('keeps the chip on the live balance when cached details are stale', () => { , ); - expect(screen.getByRole('button', { name: '泥点 207' })).toBeTruthy(); + expect( + screen.getByRole('button', { name: /可用总额 207,月度/ }), + ).toBeTruthy(); rerender(); - expect(screen.getByRole('button', { name: '泥点 195' })).toBeTruthy(); - expect(screen.queryByRole('button', { name: '泥点 207' })).toBeNull(); + expect( + screen.getByRole('button', { name: /可用总额 195,月度/ }), + ).toBeTruthy(); + expect( + screen.queryByRole('button', { name: /可用总额 207,月度/ }), + ).toBeNull(); }); test('refreshes cached balance details while open and after reopening', () => { @@ -113,7 +167,9 @@ test('refreshes cached balance details while open and after reopening', () => { />, ); - const balanceButton = screen.getByRole('button', { name: '泥点 207' }); + const balanceButton = screen.getByRole('button', { + name: /可用总额 207,月度/, + }); fireEvent.mouseEnter(balanceButton); expect(onRequestDetails).toHaveBeenCalledTimes(1); @@ -127,11 +183,15 @@ test('refreshes cached balance details while open and after reopening', () => { />, ); - expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy(); + expect( + screen.getByRole('dialog', { name: '会员与泥点账户详情' }), + ).toBeTruthy(); expect(onRequestDetails).toHaveBeenCalledTimes(2); fireEvent.pointerDown(document.body); - fireEvent.mouseEnter(screen.getByRole('button', { name: '泥点 195' })); + fireEvent.mouseEnter( + screen.getByRole('button', { name: /可用总额 195,月度/ }), + ); expect(onRequestDetails).toHaveBeenCalledTimes(3); }); @@ -149,7 +209,7 @@ test('requests missing details after the opening balance load completes', () => , ); - fireEvent.click(screen.getByRole('button', { name: '泥点 20' })); + fireEvent.click(screen.getByRole('button', { name: /可用总额 20,月度/ })); expect(onRequestDetails).not.toHaveBeenCalled(); rerender(); @@ -170,25 +230,33 @@ test('keeps the desktop panel open while moving across the gap without click pin />, ); - const balanceButton = screen.getByRole('button', { name: '泥点 207' }); + const balanceButton = screen.getByRole('button', { + name: /可用总额 207,月度/, + }); const root = balanceButton.closest('.platform-mud-point-wallet-entry'); expect(root).toBeTruthy(); fireEvent.mouseEnter(balanceButton); - const details = screen.getByRole('dialog', { name: '泥点账户详情' }); + const details = screen.getByRole('dialog', { name: '会员与泥点账户详情' }); fireEvent.mouseLeave(root as HTMLElement, { relatedTarget: null }); fireEvent.mouseEnter(details); act(() => vi.advanceTimersByTime(120)); - expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy(); + expect( + screen.getByRole('dialog', { name: '会员与泥点账户详情' }), + ).toBeTruthy(); balanceButton.focus(); fireEvent.click(balanceButton); - expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy(); + expect( + screen.getByRole('dialog', { name: '会员与泥点账户详情' }), + ).toBeTruthy(); fireEvent.mouseLeave(root as HTMLElement, { relatedTarget: null }); act(() => vi.advanceTimersByTime(120)); - expect(screen.queryByRole('dialog', { name: '泥点账户详情' })).toBeNull(); + expect( + screen.queryByRole('dialog', { name: '会员与泥点账户详情' }), + ).toBeNull(); } finally { vi.useRealTimers(); } @@ -210,7 +278,7 @@ test('requests the balance breakdown when a compact entry opens', async () => { />, ); - await user.click(screen.getByRole('button', { name: '泥点 20' })); + await user.click(screen.getByRole('button', { name: /可用总额 20,月度/ })); expect(onRequestDetails).toHaveBeenCalledTimes(1); expect(screen.getByText('余额明细暂不可用')).toBeTruthy(); }); diff --git a/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx index 5d8dd7e16..97fd52512 100644 --- a/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx @@ -15,6 +15,7 @@ import { formatMudPointResetAt, type ProfileMudPointBalanceView, resolveProfileMudPointBalanceView, + shouldShowProfileDailyFreePool, } from '../../utils/mudPoints'; export type PlatformMudPointWalletEntryProps = { @@ -312,11 +313,13 @@ export function PlatformMudPointWalletEntry({ : '会员每期额度,刷新日清零' } /> - + {shouldShowProfileDailyFreePool(breakdown) ? ( + + ) : null} ) : (
diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx index 58875c494..18db8512a 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx @@ -474,4 +474,74 @@ describe('PlatformProfileRechargeModal', () => { expect(confirmButton).toHaveProperty('disabled', true); expect(onConfirmNativePayment).not.toHaveBeenCalled(); }); + + test('hides the daily free pool when the daily grant is zero', () => { + render( + , + ); + + const dialog = screen.getByRole('dialog', { name: '会员与泥点' }); + expect(within(dialog).queryByText('每日免费')).toBeNull(); + expect(within(dialog).getByText('月度泥点')).toBeTruthy(); + expect(within(dialog).getByText('永久泥点')).toBeTruthy(); + }); + + test('shows the daily free pool while the daily grant is positive', () => { + render( + , + ); + + const dialog = screen.getByRole('dialog', { name: '会员与泥点' }); + expect(within(dialog).getByText('每日免费')).toBeTruthy(); + }); }); diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index 2b02f5c57..9c920eea9 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -17,6 +17,7 @@ import { formatMudPointCount, formatRechargePrice } from '../../utils/format'; import { formatMudPointResetAt, resolveProfileMudPointDisplayTotal, + shouldShowProfileDailyFreePool, } from '../../utils/mudPoints'; import { resolveMembershipConcurrencyLabel, @@ -376,15 +377,20 @@ function PlatformMudPointPoolOverview({ center: ProfileRechargeCenterResponse | null; }) { const balance = center?.mudPointBalance; + const showDailyFree = shouldShowProfileDailyFreePool(balance); const dailyFreeResetsAt = formatMudPointResetAt(balance?.dailyFreeResetsAt); const monthlyResetsAt = formatMudPointResetAt(balance?.monthlyResetsAt); const pools = [ - { - key: 'daily', - label: '每日免费', - points: balance?.dailyFreePoints ?? 0, - detail: `每天重置为 ${formatMudPointCount(balance?.dailyFreeResetPoints ?? 0)}`, - }, + ...(showDailyFree + ? [ + { + key: 'daily', + label: '每日免费', + points: balance?.dailyFreePoints ?? 0, + detail: `每天重置为 ${formatMudPointCount(balance?.dailyFreeResetPoints ?? 0)}`, + }, + ] + : []), { key: 'monthly', label: '月度泥点', @@ -413,7 +419,9 @@ function PlatformMudPointPoolOverview({ )}
-
+
{pools.map((pool) => (
))}
- {dailyFreeResetsAt ? ( + {showDailyFree && dailyFreeResetsAt ? (

创作扣点顺序:每日免费 → 月度 → 永久,{dailyFreeResetsAt}{' '} 重置每日免费额度。 @@ -853,6 +861,7 @@ function PlatformRechargeCheckoutDialog({ isLoadingMembershipQuote, membershipQuoteError, submittingProductId, + showDailyFreePool, onClose, onBuy, onRequestMembershipQuote, @@ -865,6 +874,7 @@ function PlatformRechargeCheckoutDialog({ isLoadingMembershipQuote: boolean; membershipQuoteError: string | null; submittingProductId: string | null; + showDailyFreePool: boolean; onClose: () => void; onBuy: (product: ProfileRechargeProduct) => void; onRequestMembershipQuote: (plan: ProfileMembershipPlan) => void; @@ -894,7 +904,11 @@ function PlatformRechargeCheckoutDialog({ value: `${formatMudPointCount(product.pointsAmount)} 永久泥点`, }, ]} - note="永久有效,不会随会员到期清零。创作时先扣每日免费与月度泥点,不足部分再扣永久泥点。" + note={ + showDailyFreePool + ? '永久有效,不会随会员到期清零。创作时先扣每日免费与月度泥点,不足部分再扣永久泥点。' + : '永久有效,不会随会员到期清零。创作时先扣月度泥点,不足部分再扣永久泥点。' + } /> ); } @@ -1213,6 +1227,9 @@ export function PlatformProfileRechargeModal({ const currentBalance = resolveProfileMudPointDisplayTotal( center?.mudPointBalance, ); + const showDailyFreePool = shouldShowProfileDailyFreePool( + center?.mudPointBalance, + ); // 后端目录已按 rank 升序返回,这里只过滤不可购买与占位档。 const membershipPlans = useMemo( () => @@ -1457,6 +1474,7 @@ export function PlatformProfileRechargeModal({ isLoadingMembershipQuote={isLoadingMembershipQuote} membershipQuoteError={membershipQuoteError} submittingProductId={submittingProductId} + showDailyFreePool={showDailyFreePool} onClose={() => setCheckout(null)} onBuy={onBuy} onRequestMembershipQuote={onRequestMembershipQuote} diff --git a/packages/shared/src/utils/mudPoints.ts b/packages/shared/src/utils/mudPoints.ts index c56568910..d65ec3ba3 100644 --- a/packages/shared/src/utils/mudPoints.ts +++ b/packages/shared/src/utils/mudPoints.ts @@ -41,6 +41,29 @@ export function resolveProfileMudPointDisplayTotal( return resolveProfileMudPointBalanceView(balance).totalPoints; } +/** + * 是否展示「每日免费」池。 + * + * 后端把每日发放额 `dailyFreeResetPoints` 配置为 0 时,表示不再提供该池, + * 前端不再展示每日免费相关入口;配置随时可改回 > 0,无需额外开关。当天仍有存量 + * (`dailyFreePoints > 0`)时仍要展示:扣点顺序不变,这部分存量会被优先扣减, + * 不能隐身。 + */ +export function shouldShowProfileDailyFreePool( + balance: + | Pick + | null + | undefined, +): boolean { + if (!balance) { + return false; + } + return ( + (balance.dailyFreePoints ?? 0) > 0 || + (balance.dailyFreeResetPoints ?? 0) > 0 + ); +} + /** * 月度泥点的刷新时刻展示文案。 * diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 31e93298f..0b6b6d8b5 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -99,8 +99,8 @@ pub fn build_runtime_profile_wallet_config_admin_upsert_input( if initial_mud_points == 0 || initial_mud_points > i64::MAX as u64 { return Err(RuntimeProfileFieldError::InvalidInitialWalletPoints); } - if daily_free_points_per_day == 0 || daily_free_points_per_day > i64::MAX as u64 { - return Err(RuntimeProfileFieldError::InvalidDailyFreePointsPerDay); + if daily_free_points_per_day > i64::MAX as u64 { + return Err(RuntimeProfileFieldError::DailyFreePointsPerDayOverflow); } Ok(RuntimeProfileWalletConfigAdminUpsertInput { admin_user_id, diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index 570ebfe05..010c50654 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -51,7 +51,7 @@ pub enum RuntimeProfileFieldError { InvalidWalletAmount, InvalidWalletMetadata, InvalidInitialWalletPoints, - InvalidDailyFreePointsPerDay, + DailyFreePointsPerDayOverflow, WalletAmountOverflow, WalletBalanceOverflow, InsufficientWalletBalance, @@ -138,8 +138,8 @@ impl std::fmt::Display for RuntimeProfileFieldError { Self::InvalidInitialWalletPoints => { f.write_str("profile_wallet_config.initial_mud_points 必须大于 0") } - Self::InvalidDailyFreePointsPerDay => { - f.write_str("profile_wallet_config.daily_free_points_per_day 必须大于 0") + Self::DailyFreePointsPerDayOverflow => { + f.write_str("profile_wallet_config.daily_free_points_per_day 超出上限") } Self::WalletAmountOverflow => f.write_str("profile.wallet_amount 超出上限"), Self::WalletBalanceOverflow => f.write_str("profile.wallet_balance 超出上限"), diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index b3e48fb39..ae6bb9fb9 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -688,7 +688,7 @@ mod tests { } #[test] - fn profile_wallet_config_requires_positive_daily_free_points() { + fn profile_wallet_config_allows_zero_daily_free_points() { assert_eq!( build_runtime_profile_wallet_config_admin_upsert_input( "admin-1".to_string(), @@ -696,19 +696,19 @@ mod tests { 1_713_680_000_000_000, 0, ) - .expect_err("zero daily free points should fail"), - RuntimeProfileFieldError::InvalidDailyFreePointsPerDay, + .expect("zero daily free points should pass") + .daily_free_points_per_day, + 0, ); assert_eq!( build_runtime_profile_wallet_config_admin_upsert_input( "admin-1".to_string(), 100, 1_713_680_000_000_000, - 35, + i64::MAX as u64 + 1, ) - .expect("positive daily free points should pass") - .daily_free_points_per_day, - 35, + .expect_err("overflowing daily free points should fail"), + RuntimeProfileFieldError::DailyFreePointsPerDayOverflow, ); } diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 8b8ee140c..a29a45263 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -1,4 +1,4 @@ -/* @vitest-environment jsdom */ +/* @vitest-environment jsdom */ import { canvasViewportToWorldTransform } from '@genarrative/image-canvas-react'; import { @@ -817,7 +817,6 @@ describe('ImageCanvasEditorView', () => { }); expect(within(details).getByText('永久泥点')).toBeTruthy(); expect(within(details).getByText('月度泥点')).toBeTruthy(); - expect(within(details).getByText('每日免费泥点')).toBeTruthy(); await user.click(within(details).getByRole('button', { name: '使用详情' })); expect( diff --git a/src/components/image-editor/ImageCanvasTopbarView.test.tsx b/src/components/image-editor/ImageCanvasTopbarView.test.tsx index da67d9064..3d1c4928a 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.test.tsx @@ -118,7 +118,6 @@ describe('ImageCanvasTopbarView', () => { }); expect(within(details).getByText('永久泥点')).toBeTruthy(); expect(within(details).getByText('月度泥点')).toBeTruthy(); - expect(within(details).getByText('每日免费泥点')).toBeTruthy(); await user.click(within(details).getByRole('button', { name: '使用详情' })); expect(props.onOpenWalletLedger).toHaveBeenCalledTimes(1); From 21f69ef13bf932dcb475bc17af375b5193606340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 17:11:04 +0800 Subject: [PATCH 085/232] =?UTF-8?q?fix(=E8=BF=81=E7=A7=BB):=20=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E4=BC=9A=E5=91=98=E9=87=8D=E6=9E=84=E7=9A=84=E7=A0=B4?= =?UTF-8?q?=E5=9D=8F=E6=80=A7=E8=BF=81=E7=A7=BB=E5=AF=BC=E5=85=A5=E5=85=BC?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migration.rs 为旧迁移包回填 profile_membership 的 plan / cycle_index / cycle_count / cycle_kind 默认值。 - migration.rs 为旧迁移包回填 profile_recharge_order 的 14 个 membership_* 快照默认值,避免回灌导入因缺列失败。 - 新增两条单元测试守护会员表与订单快照的旧包默认值。 - 同步后端架构文档的 profile_membership / profile_recharge_product_config / profile_recharge_order 表目录。 - 记录 SpacetimeDB 2.8.3 持久表删列需显式迁移的实测结论与既有 run_conflict_migration_publish 路径。 --- ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 10 ++ ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 8 +- .../crates/spacetime-module/src/migration.rs | 139 ++++++++++++++++++ 3 files changed, 153 insertions(+), 4 deletions(-) diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index d837d45b2..b8a7f00ac 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -181,6 +181,16 @@ pub struct ProfileMembershipPlan { **回退为「保留列 + 写死默认值 + 运行时不读」**,并在生成绑定与表目录里明确标注为废弃残留。 两种路径都必须先让 schema 门禁通过,不得使用 `--delete-data=always` 绕过。 +**实测结论(2026-10-03,SpacetimeDB 2.8.3 本地 standalone)**:持久表删列会被发布检查直接拒绝, +报 `Removing a column ... requires a manual migration`,仅传 `--delete-data=never --yes=migrate,break-clients` +不足以放行;`--delete-data=on-conflict` 则会清空整库。保留列的回退未采用,改用仓库既有的显式迁移路径: +`scripts/deploy-rust-remote.sh` 的 `run_conflict_migration_publish`(默认 `GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT=true`) +会 导出旧库 → 清库发布新 wasm → 回灌迁移 JSON,因此不需要新增独立迁移脚本。 +旧迁移包缺少新增列的导入兼容已在 `server-rs/crates/spacetime-module/src/migration.rs` 的 +`normalize_migration_row` 补齐(`profile_membership` 回填 `plan` / 期数默认值;`profile_recharge_order` +回填 14 个 `membership_*` 快照默认值),并由同名单元测试守护。删列本身仍受本分支的 +`SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1` schema 门禁确认。 + ### 2.8 会员侧遗留清理清单 实施时**删除**(不是废弃),并同步修掉引用它们的测试: diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index eeb525c85..93f08ce41 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -727,7 +727,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`ProfileMembership` - 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` -- 作用:会员有效期和当前周期限时泥点事实源。`started_at/expires_at` 表示会员有效期,`cycle_started_at/cycle_resets_at/cycle_period_days` 表示当前周期,`cycle_granted_points/cycle_remaining_points` 表示当前周期已发放和剩余限时泥点。 +- 作用:会员有效期和当前周期限时泥点事实源。`started_at/expires_at` 表示会员有效期,`cycle_started_at/cycle_resets_at` 表示当前周期,`cycle_granted_points/cycle_remaining_points` 表示当前周期已发放和剩余限时泥点;`plan` / `cycle_index` / `cycle_count` / `cycle_kind` 表示会员档位(`RuntimeProfileMembershipPlan` 枚举)与自然月期数。旧 `tier` 与固定 30 天的 `cycle_period_days` 已随会员档位枚举化删除(会员未上线,属经用户授权的破坏性变更;迁移导入按 `migration.rs` 的 `profile_membership` 兼容分支回填默认值)。 ### `profile_membership_plan` @@ -742,8 +742,8 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`ProfileRechargeProductConfig` - 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` -- 作用:泥点和会员充值商品配置真相源,供充值中心展示、下单校验、支付确认和后台“充值商品”页维护;当前公开充值中心只展示四档泥点商品,会员商品配置留存但不公开购买或升级入口。 -- 字段补充:会员商品追加 `membership_period_points`、`membership_period_days`、`membership_queue_limit`、`membership_discount_bps`;泥点商品这些字段必须为 `0`。 +- 作用:泥点充值商品配置真相源,供充值中心展示、下单校验、支付确认和后台“充值商品”页维护;当前公开充值中心展示六档泥点商品,会员档位与价格改由 `profile_membership_plan` 目录驱动。 +- 字段补充:原先给会员商品用的 `tier` 与 `membership_period_points` / `membership_period_days` / `membership_queue_limit` / `membership_discount_bps` 已随会员档位枚举化删除;旧迁移包里的这些列在导入时按未知字段丢弃。 ### `profile_played_world` @@ -754,7 +754,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`ProfileRechargeOrder` - 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` -- 作用:账户充值订单事实源。`status` 包含 `pending`、`paid`、`failed`、`closed`、`refunded`、`expired`;过期补偿字段 `expired_at`、`expiration_checked_at`、`expiration_provider_state`、`expiration_last_error` 用于记录本地过期和微信查单结果。 +- 作用:账户充值订单事实源。`status` 包含 `pending`、`paid`、`failed`、`closed`、`refunded`、`expired`;过期补偿字段 `expired_at`、`expiration_checked_at`、`expiration_provider_state`、`expiration_last_error` 用于记录本地过期和微信查单结果。末位追加的 `membership_*` 字段保存会员购买 / 升级前后的档位、周期、每期额度、到期与刷新时间快照、补点差额和报价明细,供人工复核退款;旧迁移包缺少这些列时由 `migration.rs` 的 `profile_recharge_order` 兼容分支回填为空 / `0`。 ### `profile_recharge_refund` diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 5018f86d8..eeb4dcf00 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1284,6 +1284,67 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde object .entry("expiration_last_error".to_string()) .or_insert(serde_json::Value::Null); + // 中文注释:会员购买 / 升级变更快照晚于订单表加入,旧迁移包按普通充点订单兼容。 + object + .entry("membership_change_kind".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_plan_before".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_plan_after".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_cycle_kind_before".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_cycle_kind_after".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_period_points_before".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_period_points_after".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_granted_points_delta".to_string()) + .or_insert_with(|| serde_json::Value::from(0i64)); + object + .entry("membership_cycle_index".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_expires_at_before".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_expires_at_after".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_cycle_resets_at_before".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_cycle_resets_at_after".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("membership_price_breakdown_json".to_string()) + .or_insert(serde_json::Value::Null); + } + } + if table_name == "profile_membership" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:会员档位改枚举、账期改自然月后新增 plan / cycle_index / cycle_count / cycle_kind。 + // 会员侧未上线、无存量行;旧迁移包若带旧 `tier` / `cycle_period_days`,反序列化时会按未知字段丢弃。 + object + .entry("plan".to_string()) + .or_insert_with(|| serde_json::json!({ "Normal": [] })); + object + .entry("cycle_index".to_string()) + .or_insert_with(|| serde_json::Value::from(0u32)); + object + .entry("cycle_count".to_string()) + .or_insert_with(|| serde_json::Value::from(1u32)); + object + .entry("cycle_kind".to_string()) + .or_insert_with(|| serde_json::json!({ "Monthly": [] })); } } if table_name == "external_generation_job_summary" { @@ -1551,6 +1612,84 @@ mod migration_bootstrap_secret_tests { assert_eq!(normalized["asset_kind_override"], serde_json::Value::Null); } + #[test] + fn old_profile_membership_rows_default_to_normal_plan_and_first_month() { + let normalized = normalize_migration_row( + "profile_membership", + &serde_json::json!({ "user_id": "user-1", "tier": { "Month": [] } }), + ); + + assert_eq!(normalized["plan"], serde_json::json!({ "Normal": [] })); + assert_eq!(normalized["cycle_index"], serde_json::json!(0)); + assert_eq!(normalized["cycle_count"], serde_json::json!(1)); + assert_eq!( + normalized["cycle_kind"], + serde_json::json!({ "Monthly": [] }) + ); + } + + #[test] + fn old_profile_recharge_order_rows_default_to_plain_point_order_snapshot() { + let normalized = normalize_migration_row( + "profile_recharge_order", + &serde_json::json!({ "order_id": "order-1" }), + ); + + assert_eq!( + normalized["membership_change_kind"], + serde_json::Value::Null + ); + assert_eq!( + normalized["membership_plan_before"], + serde_json::Value::Null + ); + assert_eq!(normalized["membership_plan_after"], serde_json::Value::Null); + assert_eq!( + normalized["membership_cycle_kind_before"], + serde_json::Value::Null + ); + assert_eq!( + normalized["membership_cycle_kind_after"], + serde_json::Value::Null + ); + assert_eq!( + normalized["membership_period_points_before"], + serde_json::Value::Null + ); + assert_eq!( + normalized["membership_period_points_after"], + serde_json::Value::Null + ); + assert_eq!( + normalized["membership_granted_points_delta"], + serde_json::json!(0) + ); + assert_eq!( + normalized["membership_cycle_index"], + serde_json::Value::Null + ); + assert_eq!( + normalized["membership_expires_at_before"], + serde_json::Value::Null + ); + assert_eq!( + normalized["membership_expires_at_after"], + serde_json::Value::Null + ); + assert_eq!( + normalized["membership_cycle_resets_at_before"], + serde_json::Value::Null + ); + assert_eq!( + normalized["membership_cycle_resets_at_after"], + serde_json::Value::Null + ); + assert_eq!( + normalized["membership_price_breakdown_json"], + serde_json::Value::Null + ); + } + #[test] fn bootstrap_secret_sha256_is_stable_and_does_not_contain_plaintext() { let secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; From af0721fb78ed8f822b490636f5370f7af7f4db25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 17:24:00 +0800 Subject: [PATCH 086/232] =?UTF-8?q?fix(=E4=BC=9A=E5=91=98):=20=E5=9B=9E?= =?UTF-8?q?=E7=81=8C=E5=90=8E=E6=94=B6=E6=95=9B=E5=85=85=E7=82=B9=E5=95=86?= =?UTF-8?q?=E5=93=81=E7=9B=AE=E5=BD=95=E5=B9=B6=E6=B8=85=E7=90=86=E9=80=80?= =?UTF-8?q?=E5=BD=B9=E4=BC=9A=E5=91=98=E5=95=86=E5=93=81=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - profile.rs 新增 converge_default_profile_recharge_product_config,在存量库读取路径补齐缺失的 points_1280 / points_3280 档位。 - 同一步骤删除已退役的 membership 类型充点商品配置行,避免冲突迁移把旧 member_* 行原样带回后残留。 - 存量库充值商品目录因此幂等收敛到当前六档点卡,不再停留在旧四档 + 四行会员商品。 --- .../src/runtime/active/profile.rs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) 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 869fc7af6..f028340c5 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -8855,6 +8855,7 @@ pub fn init_profile_membership_plan_catalog(ctx: &ReducerContext) { fn ensure_default_profile_recharge_product_config(ctx: &ReducerContext) { if ctx.db.profile_recharge_product_config().count() > 0 { migrate_legacy_default_profile_recharge_product_config(ctx); + converge_default_profile_recharge_product_config(ctx); return; } @@ -8961,6 +8962,64 @@ fn migrate_legacy_default_profile_recharge_product_config(ctx: &ReducerContext) } } +/// 存量库 / 冲突迁移回灌后把充点商品收敛到当前六档目录。 +/// +/// 迁移目的:冲突迁移会清库发布再回灌旧 `profile_recharge_product_config`;旧库里的 +/// `member_*` 会员商品行会被原样带回,而新增的 `points_1280` / `points_3280` 不在旧包里, +/// [`migrate_legacy_default_profile_recharge_product_config`] 又只归一旧行、不补缺档。 +/// 这里在读取路径幂等补齐缺失档位并删除已退役的会员商品配置行。 +fn converge_default_profile_recharge_product_config(ctx: &ReducerContext) { + let now = ctx.timestamp; + for (sort_order, product) in runtime_profile_recharge_point_products() + .into_iter() + .enumerate() + { + let product_id = product.product_id.clone(); + if ctx + .db + .profile_recharge_product_config() + .product_id() + .find(&product_id) + .is_some() + { + continue; + } + ctx.db + .profile_recharge_product_config() + .insert(ProfileRechargeProductConfig { + product_id: product.product_id, + title: product.title, + price_cents: product.price_cents, + kind: product.kind, + points_amount: product.points_amount, + bonus_points: product.bonus_points, + duration_days: product.duration_days, + badge_label: product.badge_label, + description: product.description, + enabled: true, + sort_order: sort_order as i32, + created_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(), + created_at: now, + updated_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(), + updated_at: now, + }); + } + + let retired_membership_product_ids = ctx + .db + .profile_recharge_product_config() + .iter() + .filter(|row| row.kind == RuntimeProfileRechargeProductKind::Membership) + .map(|row| row.product_id) + .collect::>(); + for product_id in retired_membership_product_ids { + ctx.db + .profile_recharge_product_config() + .product_id() + .delete(&product_id); + } +} + fn profile_recharge_product_config_rows( ctx: &ReducerContext, include_disabled: bool, From cf6004718e58dc2f545022965c1d37de61059b96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 17:27:49 +0800 Subject: [PATCH 087/232] =?UTF-8?q?fix(=E5=90=8E=E5=8F=B0=E8=AE=A2?= =?UTF-8?q?=E5=8D=95):=20=E4=BC=9A=E5=91=98=E5=8F=98=E6=9B=B4=E6=91=98?= =?UTF-8?q?=E8=A6=81=E6=97=A0=E8=B4=A6=E6=9C=9F=E6=97=B6=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E6=82=AC=E7=A9=BA=E5=88=86=E9=9A=94=E7=AC=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminRechargeOrderPage 的会员变更摘要仅在存在账期时才追加 "/ 月付|年付",避免输出 “新购 Pro / ”。 --- apps/admin-web/src/pages/AdminRechargeOrderPage.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx index 5fe0acbdf..efca3ed1d 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx @@ -1308,10 +1308,9 @@ function formatMembershipChangeSummary( before && after && before !== after ? `${before}→${after}` : (after ?? before ?? '-'); - const cycle = formatMembershipCycleKind( - change.cycleKindAfter ?? change.cycleKindBefore ?? '', - ); - return `${action} ${planPath} / ${cycle}`; + const cycleKind = change.cycleKindAfter ?? change.cycleKindBefore; + const cycle = cycleKind ? ` / ${formatMembershipCycleKind(cycleKind)}` : ''; + return `${action} ${planPath}${cycle}`; } function formatMoney(cents: number) { From 2514759949fde74dc499afaadb33806beec6447d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 17:28:17 +0800 Subject: [PATCH 088/232] =?UTF-8?q?fix(=E5=85=85=E7=82=B9=E5=BC=B9?= =?UTF-8?q?=E7=AA=97):=20=E5=8D=87=E7=BA=A7=E6=8A=A5=E4=BB=B7=E6=8C=89?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E6=A1=A3=E4=BD=8D=E4=B8=8E=E8=B4=A6=E6=9C=9F?= =?UTF-8?q?=E5=8F=8C=E9=87=8D=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PlatformProfileRechargeModal 的 quoteForTarget 同时校验 targetPlan 与 cycleKind,账期不一致的陈旧报价按“暂无报价”处理,避免展示错误金额并解锁下单。 --- .../src/components/PlatformProfileRechargeModal/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index 9c920eea9..8443044e1 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -928,7 +928,10 @@ function PlatformRechargeCheckoutDialog({ const productId = resolveMembershipProductId(target.plan, target.cycleKind); const submitting = submittingProductId === productId; const quoteForTarget = - isUpgrade && membershipQuote && membershipQuote.targetPlan === target.plan + isUpgrade && + membershipQuote && + membershipQuote.targetPlan === target.plan && + membershipQuote.cycleKind === target.cycleKind ? membershipQuote : null; const amountCents = quoteForTarget From 803d5ccb0a57e332480acd897f677ee48554df11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 17:28:55 +0800 Subject: [PATCH 089/232] =?UTF-8?q?refactor(=E5=85=85=E7=82=B9=E5=BC=B9?= =?UTF-8?q?=E7=AA=97):=20=E5=8D=87=E7=BA=A7=E6=8A=A5=E4=BB=B7=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=94=B9=E7=94=B1=E5=85=B7=E5=90=8D=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E8=BF=94=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽出 resolveMembershipQuoteErrorState,用提前返回替代嵌套三元,消除升级报价错误 / 加载 / 空三态的深层条件表达式。 --- .../PlatformProfileRechargeModal/index.tsx | 58 +++++++++++++------ 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index 8443044e1..31273ef13 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -371,6 +371,41 @@ function resolveMembershipPlanAction( return { disabled: false, label: '补差升级', mode: 'upgrade' }; } +/** 升级报价的加载 / 失败态;非升级流程不展示报价状态。 */ +function resolveMembershipQuoteErrorState({ + isUpgrade, + error, + isLoading, + onRetry, +}: { + isUpgrade: boolean; + error: string | null; + isLoading: boolean; + onRetry: () => void; +}): ReactNode { + if (!isUpgrade) { + return null; + } + if (error) { + return ( + +

{error}
+ + 重新获取报价 + + + ); + } + if (isLoading) { + return ( +
+ 正在获取升级报价… +
+ ); + } + return null; +} + function PlatformMudPointPoolOverview({ center, }: { @@ -1011,23 +1046,12 @@ function PlatformRechargeCheckoutDialog({ ? '按剩余比例补差,保留现有余额,已用点不返还;升级后原刷新日与到期日不变。' : `月度泥点按开通日自然月计期,当期未用完部分到期清零,不结转。当前选择 ${cycleLabel}。` } - errorState={ - isUpgrade && membershipQuoteError ? ( - -
{membershipQuoteError}
- onRequestMembershipQuote(target.plan)} - > - 重新获取报价 - -
- ) : isUpgrade && isLoadingMembershipQuote ? ( -
- 正在获取升级报价… -
- ) : null - } + errorState={resolveMembershipQuoteErrorState({ + isUpgrade, + error: membershipQuoteError, + isLoading: isLoadingMembershipQuote, + onRetry: () => onRequestMembershipQuote(target.plan), + })} /> ); } From cec4b7ec5e16cca51efbb4e548ba1b826e470d40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 17:30:05 +0800 Subject: [PATCH 090/232] =?UTF-8?q?fix(=E5=85=85=E7=82=B9=E5=BC=B9?= =?UTF-8?q?=E7=AA=97):=20=E6=96=B9=E5=90=91=E9=94=AE=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E9=A1=B5=E7=AD=BE=E4=B8=8E=E6=A1=A3=E4=BD=8D=E5=90=8E=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=84=A6=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PlatformRechargeTabNav 与 PlatformPointProductGrid 在方向键 / Home / End 改变选中项后,用 ref 把焦点移到新按钮,保持 roving tabindex 焦点与选中一致。 --- .../PlatformProfileRechargeModal/index.tsx | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index 31273ef13..e8f9aff71 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -2,7 +2,14 @@ import './index.css'; import QRCode from 'qrcode'; import type { ReactNode } from 'react'; -import { useCallback, useEffect, useId, useMemo, useState } from 'react'; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; import type { ProfileMembership, @@ -495,6 +502,8 @@ function PlatformRechargeTabNav({ { id: 'subscription', label: '会员订阅' }, { id: 'recharge', label: '泥点充值' }, ]; + // 方向键移动选中项后把焦点一并移到新页签,保持 roving tabindex 的焦点与选中一致。 + const tabRefs = useRef>({}); return (
@@ -527,6 +537,9 @@ function PlatformRechargeTabNav({ return (
- {formatRechargePrice(amountCents)} + {amountPending ? '—' : formatRechargePrice(amountCents)}
{errorState ?
{errorState}
: null} From 582a329468fe073f915733dcac3e5184511c7a17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 10:33:04 +0800 Subject: [PATCH 119/232] =?UTF-8?q?=E4=BF=AE=E6=AD=A3(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E5=BC=B9=E5=B1=82)=EF=BC=9A=E6=94=AF=E4=BB=98=E6=96=B9?= =?UTF-8?q?=E5=BC=8F=E9=9D=99=E6=80=81=E6=96=87=E6=A1=88=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=20radiogroup/radio=20=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 微信支付是纯展示的单一选项,不可交互的 radio 会误导读屏 - 改为普通文本容器,视觉样式不变 --- .../PlatformProfileRechargeModal/index.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index aaec13d74..0f9c9eaea 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -1175,16 +1175,10 @@ function PlatformRechargeCheckoutShell({
支付方式
-
- + {/* 中文注释:当前只支持微信支付,是纯展示文案,不用 radiogroup / radio 语义(不可交互的 + 单选项会误导读屏)。 */} +
+ 微 From e855c52d7f91ec024edcb3cf8470b87813bba935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 10:33:15 +0800 Subject: [PATCH 120/232] =?UTF-8?q?=E6=96=87=E6=A1=A3(=E5=81=87=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E8=84=9A=E6=9C=AC)=EF=BC=9A=E6=B3=A8=E6=98=8E?= =?UTF-8?q?=E4=B8=8D=E9=99=90=E5=B9=B6=E5=8F=91=E5=93=A8=E5=85=B5=E5=AF=B9?= =?UTF-8?q?=E5=BA=94=E7=9A=84=E5=90=8E=E7=AB=AF=E5=B8=B8=E9=87=8F=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在注释里给出 limits.rs 与 MEMBERSHIP_UNLIMITED_CONCURRENCY 定义,便于后端改动时同步 --- scripts/admin-web-fake-api.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/admin-web-fake-api.mjs b/scripts/admin-web-fake-api.mjs index 7e3b21909..e017c4078 100644 --- a/scripts/admin-web-fake-api.mjs +++ b/scripts/admin-web-fake-api.mjs @@ -4,7 +4,11 @@ import http from 'node:http'; const port = Number(process.argv[2] ?? 8099); -/** 与后端 `MEMBERSHIP_UNLIMITED_CONCURRENCY` 一致的假数据哨兵。 */ +/** 与后端 `MEMBERSHIP_UNLIMITED_CONCURRENCY` 一致的假数据哨兵。 + * + * 后端定义:`server-rs/crates/module-runtime/src/membership/limits.rs` + * (`pub const MEMBERSHIP_UNLIMITED_CONCURRENCY: u32 = 128`;`>=` 该值视为不设上限)。 + * 改后端常量时请同步这里,否则本地预览的「不限并发」会与线上不一致。 */ const UNLIMITED_CONCURRENCY_THRESHOLD = 128; const admin = { From b0b1e2519ef7ac4a30591640730d661541c08b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 10:33:52 +0800 Subject: [PATCH 121/232] =?UTF-8?q?=E6=96=87=E6=A1=A3(=E6=97=A5=E5=8E=86)?= =?UTF-8?q?=EF=BC=9A=E6=B3=A8=E6=98=8E=20civil=5Ffrom=5Fdays=20=E7=9A=84?= =?UTF-8?q?=E5=90=88=E6=B3=95=E8=BE=93=E5=85=A5=E8=8C=83=E5=9B=B4=E4=B8=8E?= =?UTF-8?q?=E9=A5=B1=E5=92=8C=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 说明极端日序号会饱和并给出无意义日期,要求调用方保证输入范围 --- server-rs/crates/module-runtime/src/civil_calendar.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server-rs/crates/module-runtime/src/civil_calendar.rs b/server-rs/crates/module-runtime/src/civil_calendar.rs index 036bf1a2d..5b43da801 100644 --- a/server-rs/crates/module-runtime/src/civil_calendar.rs +++ b/server-rs/crates/module-runtime/src/civil_calendar.rs @@ -34,6 +34,10 @@ pub fn days_from_civil(year: i32, month: u8, day: u8) -> i64 { } /// 1970-01-01 起的日序号 → 公历年月日。 +/// +/// 只接受由 [`days_from_civil`] 产生、或落在公历可表示范围内的日序号(约 ±7.9e11 天,即 +/// `i32` 年份区间)。内部用 `saturating_add` 防止中间结果溢出:传入远小于/远大于该范围的 +/// 极端值时不会 panic,但会饱和并返回无意义的年月日,因此调用方需自行保证输入范围。 pub fn civil_from_days(date_key: i64) -> (i32, u8, u8) { // 中文注释:days_from_civil 的反向算法,避免依赖运行环境时区。 let z = date_key.saturating_add(719_468); From cc30d8ba7a779f4c441ea5bf7f3254976f610222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 10:33:55 +0800 Subject: [PATCH 122/232] =?UTF-8?q?=E4=BF=AE=E6=AD=A3(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E8=B4=A6=E6=9C=9F)=EF=BC=9Abeijing=5Flocal=5Fmicros=20?= =?UTF-8?q?=E6=94=B9=E7=94=A8=20checked=20=E8=BF=90=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 年份导致微秒溢出时返回 None,符合函数文档,不再饱和成看似合法的值 - 补测试覆盖 i64 溢出年份返回 None --- .../crates/module-runtime/src/membership/cycle.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/server-rs/crates/module-runtime/src/membership/cycle.rs b/server-rs/crates/module-runtime/src/membership/cycle.rs index 306f8afab..2b29f37e6 100644 --- a/server-rs/crates/module-runtime/src/membership/cycle.rs +++ b/server-rs/crates/module-runtime/src/membership/cycle.rs @@ -34,9 +34,9 @@ pub fn beijing_local_micros(year: i32, month: u8, day: u8, time_of_day_micros: i } Some( days_from_civil(year, month, day) - .saturating_mul(PROFILE_RUNTIME_DAY_MICROS) - .saturating_add(time_of_day_micros) - .saturating_sub(PROFILE_TASK_BEIJING_OFFSET_MICROS), + .checked_mul(PROFILE_RUNTIME_DAY_MICROS)? + .checked_add(time_of_day_micros)? + .checked_sub(PROFILE_TASK_BEIJING_OFFSET_MICROS)?, ) } @@ -119,6 +119,13 @@ mod tests { ); } + #[test] + fn beijing_local_micros_rejects_overflowing_year() { + // 中文注释:超出 i64 微秒范围的年份必须返回 None,而不是饱和成看似合法的值。 + assert_eq!(beijing_local_micros(1_000_000, 1, 1, 0), None); + assert_eq!(beijing_local_micros(-1_000_000, 1, 1, 0), None); + } + #[test] fn advance_months_clamps_to_month_end() { // 中文注释:1/31 开通 → 2/28(2026 非闰年)→ 3/31,每期都从原始锚点重算。 From 44feb7b0f5bd06f1977a9006d837935a57abf9c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 10:33:58 +0800 Subject: [PATCH 123/232] =?UTF-8?q?=E4=BF=AE=E6=AD=A3(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E5=90=8E=E5=8F=B0)=EF=BC=9A=E7=BC=BA=E5=B0=91=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E6=A1=A3=E4=BD=8D=E6=97=B6=E4=B8=8D=E5=86=8D=E5=9B=9E?= =?UTF-8?q?=E9=80=80=20rank=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - upsert 解析 rank 失败改为显式报错,避免与 Normal 占位同档 - 与 module-runtime 用 Option 暴露缺失档位的契约保持一致 --- .../spacetime-module/src/runtime/active/profile.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 2d25e248a..fff05e9dd 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -8383,9 +8383,15 @@ fn upsert_profile_membership_plan_record( .plan() .find(&validated.plan); // 代码内目录是 `rank` 的唯一来源:后台改价、改权益、改上下架都不会动升级比较基准。 + // 缺失档位必须显式报错:回退成 rank 0 会与 Normal 占位同档,产生误导性的校验结果。 let rank = resolve_runtime_profile_membership_plan_row(validated.plan) - .map(|row| row.rank) - .unwrap_or(0); + .ok_or_else(|| { + format!( + "会员目录缺少档位 {},无法解析升级比较基准", + validated.plan.as_str() + ) + })? + .rank; let inserted = ProfileMembershipPlan { plan: validated.plan, From 4a2594a90ce16de13cf6e57b740c8133a1c85886 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 10:35:01 +0800 Subject: [PATCH 124/232] =?UTF-8?q?=E4=BF=AE=E6=AD=A3(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E6=94=AF=E4=BB=98)=EF=BC=9A=E5=BE=AE=E4=BF=A1=E8=99=9A?= =?UTF-8?q?=E6=8B=9F=E6=94=AF=E4=BB=98=E5=8F=AA=E6=8E=A5=E5=8F=97=E5=9C=A8?= =?UTF-8?q?=E5=94=AE=E4=B8=94=E5=8F=AF=E8=B4=AD=E4=B9=B0=E7=9A=84=E6=A1=A3?= =?UTF-8?q?=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build_wechat_virtual_pay_params 的档位校验补齐 enabled 与 is_purchasable - 与 resolve_enabled_profile_recharge_order_product 的可售判定对齐,拒绝 Normal 占位与下架档位 --- server-rs/crates/api-server/src/runtime_profile.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index fbdd8bdc2..ae0bfdb73 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1821,14 +1821,18 @@ fn build_wechat_virtual_pay_params( .with_message("当前充值商品不存在,请刷新后再试") })?, RuntimeProfileRechargeProductKind::Membership => { - // 会员商品不在商品配置表里:确认档位仍在目录中即可,购买数量固定为 1。 + // 会员商品不在商品配置表里:确认档位仍可售(在售且非 Normal 占位)即可,购买数量固定为 1。 let (plan, _) = module_runtime::parse_runtime_profile_membership_product_id(&order.product_id) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST) .with_message("当前会员套餐不存在,请刷新后再试") })?; - if !center.membership_plans.iter().any(|item| item.plan == plan) { + if !center + .membership_plans + .iter() + .any(|item| item.plan == plan && item.enabled && item.plan.is_purchasable()) + { return Err(AppError::from_status(StatusCode::BAD_REQUEST) .with_message("当前会员套餐不存在,请刷新后再试")); } From 168028d5e554ba466bcad71e52e8b0e85d4fff34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 11:21:42 +0800 Subject: [PATCH 125/232] =?UTF-8?q?=E4=BF=AE=E6=AD=A3(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E8=B4=A6=E6=9C=9F)=EF=BC=9A=E6=9C=9F=E5=8F=B7=E6=B0=B4?= =?UTF-8?q?=E4=BD=8D=E7=BC=BA=E5=A4=B1=E4=B8=8D=E5=86=8D=E8=A2=AB=E5=BD=93?= =?UTF-8?q?=E6=88=90=E6=8D=A2=E6=9C=9F=E9=87=8D=E5=8F=91=E9=A2=9D=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - advance_profile_membership_cycle_to 只在 cycle_resets_at 缺失时判定需要初始化写库 - cycle_index == 0(新列默认值)只补写期号水位,不重置 cycle_granted/remaining_points - 补测试覆盖 cycle_index=0 且账期窗口已存在时保留本期余额 --- .../src/runtime/active/profile.rs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) 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 fff05e9dd..e37eed3dc 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -4192,6 +4192,18 @@ mod tests { assert_eq!(same_period.row.cycle_index, 1); assert_eq!(same_period.row.cycle_remaining_points, 120); + // 期号水位为 0(新列默认值)但账期窗口已存在:只补水期号,不重发本期额度。 + let mut stale_watermark = row(); + stale_watermark.cycle_index = 0; + let backfilled = advance_profile_membership_cycle_to( + stale_watermark, + Timestamp::from_micros_since_unix_epoch(anchor + 10 * day), + 400, + ); + assert!(!backfilled.advanced, "期号水位缺失不应被当成换期"); + assert_eq!(backfilled.row.cycle_index, 1); + assert_eq!(backfilled.row.cycle_remaining_points, 120); + let next_period = advance_profile_membership_cycle_to( row(), Timestamp::from_micros_since_unix_epoch(anchor + 28 * day), @@ -9424,7 +9436,13 @@ fn advance_profile_membership_cycle_to( // 起点取已落库的期号(水位下限),保证只前进、不后退。 let mut cycle_index = row.cycle_index.max(1); let mut window = membership_cycle_window(anchor_micros, cycle_index); - let mut advanced = row.cycle_resets_at.is_none() || row.cycle_index == 0; + // `cycle_resets_at` 缺失 = 账期窗口尚未初始化,需要写库补种并发放本期额度; + // `cycle_index == 0` 只是期号水位没写(新列默认值),不能据此判定越界、重发额度。 + let mut advanced = row.cycle_resets_at.is_none(); + if row.cycle_index == 0 { + // 仅补写期号水位:账期额度只应在真正越过边界时重置。 + row.cycle_index = 1; + } // 仅当 `at` 已越过当前窗口结束时刻才推进;时钟回拨时条件不成立,整行保持原状。 while window.1 <= at_micros && cycle_index < max_index { cycle_index = cycle_index.saturating_add(1); From 1744262b0b1971ee189e3fc38556534cbfbf437c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 11:23:21 +0800 Subject: [PATCH 126/232] =?UTF-8?q?=E5=AF=B9=E9=BD=90(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E8=AE=A2=E5=8D=95)=EF=BC=9Agranted=5Fpoints=5Fdelta=20?= =?UTF-8?q?=E5=85=A8=E9=93=BE=E8=B7=AF=20i64=20=E6=94=B9=20u64?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - profile_recharge_order.membership_granted_points_delta 列由 i64 改为 u64 - RuntimeProfileMembershipOrderChangeSnapshot 与 AdminMembershipOrderChangePayload 同步改 u64 - 删除 snapshot/order 之间的 as i64 与 try_from 兜底转换 - 重生成 SpacetimeDB 绑定:profile_recharge_order、订单变更快照 --- server-rs/crates/module-runtime/src/domain.rs | 2 +- server-rs/crates/shared-contracts/src/admin.rs | 2 +- .../module_bindings/profile_recharge_order_type.rs | 4 ++-- ...rofile_membership_order_change_snapshot_type.rs | 2 +- .../spacetime-module/src/runtime/active/profile.rs | 14 +++++++------- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 53dba4bec..75010d661 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1750,7 +1750,7 @@ pub struct RuntimeProfileMembershipOrderChangeSnapshot { pub cycle_kind_after: Option, pub period_points_before: Option, pub period_points_after: Option, - pub granted_points_delta: i64, + pub granted_points_delta: u64, pub cycle_index: Option, pub expires_at_before_micros: Option, pub expires_at_after_micros: Option, diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 4db72aecc..8d3137032 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -1250,7 +1250,7 @@ pub struct AdminMembershipOrderChangePayload { pub cycle_kind_after: Option, pub period_points_before: Option, pub period_points_after: Option, - pub granted_points_delta: i64, + pub granted_points_delta: u64, pub cycle_index: Option, pub expires_at_before_micros: Option, pub expires_at_after_micros: Option, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_type.rs index 573be0d91..6b8d3bf72 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_type.rs @@ -37,7 +37,7 @@ pub struct ProfileRechargeOrder { pub membership_cycle_kind_after: Option, pub membership_period_points_before: Option, pub membership_period_points_after: Option, - pub membership_granted_points_delta: i64, + pub membership_granted_points_delta: u64, pub membership_cycle_index: Option, pub membership_expires_at_before: Option<__sdk::Timestamp>, pub membership_expires_at_after: Option<__sdk::Timestamp>, @@ -95,7 +95,7 @@ pub struct ProfileRechargeOrderCols { __sdk::__query_builder::Col>, pub membership_period_points_after: __sdk::__query_builder::Col>, - pub membership_granted_points_delta: __sdk::__query_builder::Col, + pub membership_granted_points_delta: __sdk::__query_builder::Col, pub membership_cycle_index: __sdk::__query_builder::Col>, pub membership_expires_at_before: __sdk::__query_builder::Col>, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_order_change_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_order_change_snapshot_type.rs index bbef8b8d4..4e2f023a7 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_order_change_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_order_change_snapshot_type.rs @@ -14,7 +14,7 @@ pub struct RuntimeProfileMembershipOrderChangeSnapshot { pub cycle_kind_after: Option, pub period_points_before: Option, pub period_points_after: Option, - pub granted_points_delta: i64, + pub granted_points_delta: u64, pub cycle_index: Option, pub expires_at_before_micros: Option, pub expires_at_after_micros: Option, 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 e37eed3dc..a0e4b35d8 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -567,8 +567,8 @@ pub struct ProfileRechargeOrder { pub(crate) membership_period_points_before: Option, #[default(None::)] pub(crate) membership_period_points_after: Option, - #[default(0i64)] - pub(crate) membership_granted_points_delta: i64, + #[default(0u64)] + pub(crate) membership_granted_points_delta: u64, #[default(None::)] pub(crate) membership_cycle_index: Option, #[default(None::)] @@ -9149,7 +9149,7 @@ struct ProfileMembershipChangeSnapshot { cycle_kind_after: Option, period_points_before: Option, period_points_after: Option, - granted_points_delta: i64, + granted_points_delta: u64, cycle_index: Option, expires_at_before_micros: Option, expires_at_after_micros: Option, @@ -9219,7 +9219,7 @@ fn apply_profile_membership_order_snapshot_to_order( order.membership_cycle_kind_after = Some(snapshot.cycle_kind); order.membership_period_points_before = snapshot.period_points_before; order.membership_period_points_after = Some(snapshot.period_points); - order.membership_granted_points_delta = snapshot.grant_points as i64; + order.membership_granted_points_delta = snapshot.grant_points; order.membership_cycle_index = snapshot.cycle_index_before; order.membership_cycle_resets_at_before = snapshot .cycle_resets_at_before_micros @@ -9236,7 +9236,7 @@ fn frozen_profile_membership_settlement( plan: order.membership_plan_after?, cycle_kind: order.membership_cycle_kind_after?, period_points: order.membership_period_points_after?, - grant_points: u64::try_from(order.membership_granted_points_delta).ok()?, + grant_points: order.membership_granted_points_delta, plan_before: order.membership_plan_before, cycle_kind_before: order.membership_cycle_kind_before, period_points_before: order.membership_period_points_before, @@ -10269,7 +10269,7 @@ fn apply_profile_membership_upgrade( cycle_kind_after: Some(cycle_kind), period_points_before: settlement.period_points_before, period_points_after: Some(settlement.period_points), - granted_points_delta: settlement.grant_points as i64, + granted_points_delta: settlement.grant_points, cycle_index: Some(cycle_index), expires_at_before_micros: Some(expires_at_before_micros), expires_at_after_micros: Some(expires_at.to_micros_since_unix_epoch()), @@ -10355,7 +10355,7 @@ fn apply_profile_membership_fresh_purchase( .as_ref() .map(|row| row.cycle_granted_points), period_points_after: Some(settlement.period_points), - granted_points_delta: settlement.period_points as i64, + granted_points_delta: settlement.period_points, cycle_index: Some(1), expires_at_before_micros: expired_existing .as_ref() From c660ca41fe4cbe8359f73b5a9f71434c40a240ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 11:43:57 +0800 Subject: [PATCH 127/232] =?UTF-8?q?=E6=9E=9A=E4=B8=BE=E5=8C=96(=E4=BC=9A?= =?UTF-8?q?=E5=91=98)=EF=BC=9A=E5=BF=AB=E7=85=A7=E4=B8=8E=E6=8A=A5?= =?UTF-8?q?=E4=BB=B7/=E6=A1=A3=E4=BD=8D=E5=93=8D=E5=BA=94=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20typed=20enum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared-contracts 新增 ProfileMembershipPlan/CycleKind/ModelAccess/ChangeKind Token 枚举 - RuntimeProfileMembershipOrderChangeSnapshot 与 UpgradeQuoteRecord 档位/周期改用模块枚举 - AdminMembershipOrderChangePayload、ProfileMembership/Plan/UpgradeQuote Response 改用 token 枚举 - module-runtime 提供模块枚举与 wire token 的双向 From 映射 - api-server / spacetime-client / spacetime-module 同步去掉 as_str().to_string() 拼装 - 重生成 SpacetimeDB 绑定:订单变更快照字段改为枚举 --- .../crates/api-server/src/admin_recharge.rs | 10 +- .../crates/api-server/src/runtime_profile.rs | 22 ++-- .../crates/module-runtime/src/application.rs | 6 +- server-rs/crates/module-runtime/src/domain.rs | 106 +++++++++++++++-- .../crates/shared-contracts/src/admin.rs | 14 ++- .../crates/shared-contracts/src/runtime.rs | 109 +++++++++++++++--- .../src/active/mapper/runtime_profile.rs | 31 ++++- ...e_membership_order_change_snapshot_type.rs | 14 ++- .../src/runtime/active/profile.rs | 18 +-- 9 files changed, 256 insertions(+), 74 deletions(-) diff --git a/server-rs/crates/api-server/src/admin_recharge.rs b/server-rs/crates/api-server/src/admin_recharge.rs index 1d54a189f..805ca679c 100644 --- a/server-rs/crates/api-server/src/admin_recharge.rs +++ b/server-rs/crates/api-server/src/admin_recharge.rs @@ -995,11 +995,11 @@ fn map_membership_order_change( change: RuntimeProfileMembershipOrderChangeSnapshot, ) -> AdminMembershipOrderChangePayload { AdminMembershipOrderChangePayload { - change_kind: change.change_kind, - plan_before: change.plan_before, - plan_after: change.plan_after, - cycle_kind_before: change.cycle_kind_before, - cycle_kind_after: change.cycle_kind_after, + change_kind: change.change_kind.into(), + plan_before: change.plan_before.map(Into::into), + plan_after: change.plan_after.map(Into::into), + cycle_kind_before: change.cycle_kind_before.map(Into::into), + cycle_kind_after: change.cycle_kind_after.map(Into::into), period_points_before: change.period_points_before, period_points_after: change.period_points_after, granted_points_delta: change.granted_points_delta, diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index ae0bfdb73..def489579 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -252,13 +252,7 @@ pub async fn get_profile_membership_upgrade_quote( Json(payload): Json, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); - let target_plan = - parse_runtime_profile_membership_plan(&payload.target_plan).map_err(|error| { - runtime_profile_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), - ) - })?; + let target_plan = payload.target_plan.into(); let quote = state .spacetime_client() .get_profile_membership_upgrade_quote(user_id, target_plan) @@ -273,9 +267,9 @@ pub async fn get_profile_membership_upgrade_quote( Ok(json_success_body( Some(&request_context), ProfileMembershipUpgradeQuoteResponse { - current_plan: quote.current_plan, - target_plan: quote.target_plan, - cycle_kind: quote.cycle_kind, + current_plan: quote.current_plan.into(), + target_plan: quote.target_plan.into(), + cycle_kind: quote.cycle_kind.into(), cycle_index: quote.cycle_index, cycle_count: quote.cycle_count, amount_cents: quote.amount_cents, @@ -1981,7 +1975,7 @@ fn build_profile_recharge_center_response( mud_point_balance, membership: ProfileMembershipResponse { status: record.membership.status.as_str().to_string(), - plan: record.membership.plan.as_str().to_string(), + plan: record.membership.plan.into(), started_at: record.membership.started_at, expires_at: record.membership.expires_at, updated_at: record.membership.updated_at, @@ -1991,7 +1985,7 @@ fn build_profile_recharge_center_response( cycle_remaining_points: record.membership.cycle_remaining_points, cycle_index: record.membership.cycle_index, cycle_count: record.membership.cycle_count, - cycle_kind: record.membership.cycle_kind.as_str().to_string(), + cycle_kind: record.membership.cycle_kind.into(), }, point_products: record .point_products @@ -2075,13 +2069,13 @@ fn build_profile_membership_plan_response( record: RuntimeProfileMembershipPlanRecord, ) -> ProfileMembershipPlanResponse { ProfileMembershipPlanResponse { - plan: record.plan.as_str().to_string(), + plan: record.plan.into(), title: record.title, rank: record.rank, month_price_cents: record.month_price_cents, year_price_cents: record.year_price_cents, period_points: record.period_points, - model_access: record.model_access.as_str().to_string(), + model_access: record.model_access.into(), concurrent_job_limit: record.concurrent_job_limit, recommended: record.plan == PROFILE_MEMBERSHIP_RECOMMENDED_PLAN, unlimited_concurrency: is_unlimited_concurrency(record.concurrent_job_limit), diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index ab4f9d49a..38454c755 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -1132,9 +1132,9 @@ pub fn build_runtime_profile_membership_upgrade_quote_record( snapshot: RuntimeProfileMembershipUpgradeQuoteSnapshot, ) -> RuntimeProfileMembershipUpgradeQuoteRecord { RuntimeProfileMembershipUpgradeQuoteRecord { - current_plan: snapshot.current_plan.as_str().to_string(), - target_plan: snapshot.target_plan.as_str().to_string(), - cycle_kind: snapshot.cycle_kind.as_str().to_string(), + current_plan: snapshot.current_plan, + target_plan: snapshot.target_plan, + cycle_kind: snapshot.cycle_kind, cycle_index: snapshot.cycle_index, cycle_count: snapshot.cycle_count, amount_cents: snapshot.amount_cents, diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 75010d661..55e978e73 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1377,6 +1377,96 @@ impl RuntimeProfileMembershipChangeKind { } } +impl From for shared_contracts::runtime::ProfileMembershipPlanToken { + fn from(value: RuntimeProfileMembershipPlan) -> Self { + match value { + RuntimeProfileMembershipPlan::Normal => Self::Normal, + RuntimeProfileMembershipPlan::Starter => Self::Starter, + RuntimeProfileMembershipPlan::Plus => Self::Plus, + RuntimeProfileMembershipPlan::Pro => Self::Pro, + RuntimeProfileMembershipPlan::Max => Self::Max, + } + } +} + +impl From for RuntimeProfileMembershipPlan { + fn from(value: shared_contracts::runtime::ProfileMembershipPlanToken) -> Self { + match value { + shared_contracts::runtime::ProfileMembershipPlanToken::Normal => Self::Normal, + shared_contracts::runtime::ProfileMembershipPlanToken::Starter => Self::Starter, + shared_contracts::runtime::ProfileMembershipPlanToken::Plus => Self::Plus, + shared_contracts::runtime::ProfileMembershipPlanToken::Pro => Self::Pro, + shared_contracts::runtime::ProfileMembershipPlanToken::Max => Self::Max, + } + } +} + +impl From + for shared_contracts::runtime::ProfileMembershipCycleKindToken +{ + fn from(value: RuntimeProfileMembershipCycleKind) -> Self { + match value { + RuntimeProfileMembershipCycleKind::Monthly => Self::Monthly, + RuntimeProfileMembershipCycleKind::Yearly => Self::Yearly, + } + } +} + +impl From + for RuntimeProfileMembershipCycleKind +{ + fn from(value: shared_contracts::runtime::ProfileMembershipCycleKindToken) -> Self { + match value { + shared_contracts::runtime::ProfileMembershipCycleKindToken::Monthly => Self::Monthly, + shared_contracts::runtime::ProfileMembershipCycleKindToken::Yearly => Self::Yearly, + } + } +} + +impl From + for shared_contracts::runtime::ProfileMembershipModelAccessToken +{ + fn from(value: RuntimeProfileMembershipModelAccess) -> Self { + match value { + RuntimeProfileMembershipModelAccess::Basic => Self::Basic, + RuntimeProfileMembershipModelAccess::Full => Self::Full, + } + } +} + +impl From + for RuntimeProfileMembershipModelAccess +{ + fn from(value: shared_contracts::runtime::ProfileMembershipModelAccessToken) -> Self { + match value { + shared_contracts::runtime::ProfileMembershipModelAccessToken::Basic => Self::Basic, + shared_contracts::runtime::ProfileMembershipModelAccessToken::Full => Self::Full, + } + } +} + +impl From + for shared_contracts::runtime::ProfileMembershipChangeKindToken +{ + fn from(value: RuntimeProfileMembershipChangeKind) -> Self { + match value { + RuntimeProfileMembershipChangeKind::Purchase => Self::Purchase, + RuntimeProfileMembershipChangeKind::Upgrade => Self::Upgrade, + } + } +} + +impl From + for RuntimeProfileMembershipChangeKind +{ + fn from(value: shared_contracts::runtime::ProfileMembershipChangeKindToken) -> Self { + match value { + shared_contracts::runtime::ProfileMembershipChangeKindToken::Purchase => Self::Purchase, + shared_contracts::runtime::ProfileMembershipChangeKindToken::Upgrade => Self::Upgrade, + } + } +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimeProfileRechargeOrderStatus { @@ -1743,11 +1833,11 @@ pub struct RuntimeProfileRechargeOrderAdminEntrySnapshot { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileMembershipOrderChangeSnapshot { /// `purchase` 或 `upgrade`。 - pub change_kind: String, - pub plan_before: Option, - pub plan_after: Option, - pub cycle_kind_before: Option, - pub cycle_kind_after: Option, + pub change_kind: RuntimeProfileMembershipChangeKind, + pub plan_before: Option, + pub plan_after: Option, + pub cycle_kind_before: Option, + pub cycle_kind_after: Option, pub period_points_before: Option, pub period_points_after: Option, pub granted_points_delta: u64, @@ -1908,9 +1998,9 @@ pub struct RuntimeProfileMembershipUpgradeQuoteSnapshot { /// 补差升级报价的只读投影(记录侧,附 RFC3339 时间)。 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileMembershipUpgradeQuoteRecord { - pub current_plan: String, - pub target_plan: String, - pub cycle_kind: String, + pub current_plan: RuntimeProfileMembershipPlan, + pub target_plan: RuntimeProfileMembershipPlan, + pub cycle_kind: RuntimeProfileMembershipCycleKind, pub cycle_index: u32, pub cycle_count: u32, pub amount_cents: u64, diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 8d3137032..6516d4e20 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -3,6 +3,10 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::runtime::{ + ProfileMembershipChangeKindToken, ProfileMembershipCycleKindToken, ProfileMembershipPlanToken, +}; + #[cfg(any())] use crate::creation_entry_config::{ CreationEntryEventBannerResponse, PublicWorkInteractionConfigResponse, @@ -1243,11 +1247,11 @@ pub struct AdminRechargeRefundHoldPayload { #[serde(rename_all = "camelCase")] pub struct AdminMembershipOrderChangePayload { /// `purchase` 或 `upgrade`。 - pub change_kind: String, - pub plan_before: Option, - pub plan_after: Option, - pub cycle_kind_before: Option, - pub cycle_kind_after: Option, + pub change_kind: ProfileMembershipChangeKindToken, + pub plan_before: Option, + pub plan_after: Option, + pub cycle_kind_before: Option, + pub cycle_kind_after: Option, pub period_points_before: Option, pub period_points_after: Option, pub granted_points_delta: u64, diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 4036e3d25..bab42b467 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -246,6 +246,83 @@ pub struct ProfileWalletLedgerResponse { pub entries: Vec, } +/// 会员档位 token;与前端 `ProfileMembershipPlan` 联合类型一一对应。 +/// +/// 用枚举而非 `String` 收口 wire 值:未知 token 在反序列化阶段即被拒绝,后台也不能再 +/// 写入任意档位字符串。 +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ProfileMembershipPlanToken { + Normal, + Starter, + Plus, + Pro, + Max, +} + +impl ProfileMembershipPlanToken { + pub fn as_str(&self) -> &'static str { + match self { + Self::Normal => "normal", + Self::Starter => "starter", + Self::Plus => "plus", + Self::Pro => "pro", + Self::Max => "max", + } + } +} + +/// 会员购买周期 token;与前端 `ProfileMembershipCycleKind` 一一对应。 +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ProfileMembershipCycleKindToken { + Monthly, + Yearly, +} + +impl ProfileMembershipCycleKindToken { + pub fn as_str(&self) -> &'static str { + match self { + Self::Monthly => "monthly", + Self::Yearly => "yearly", + } + } +} + +/// 模型权限 token;与前端 `ProfileMembershipModelAccess` 一一对应。 +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ProfileMembershipModelAccessToken { + Basic, + Full, +} + +impl ProfileMembershipModelAccessToken { + pub fn as_str(&self) -> &'static str { + match self { + Self::Basic => "basic", + Self::Full => "full", + } + } +} + +/// 会员订单权益变更类型 token;区分“新购”与“补差升级”。 +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ProfileMembershipChangeKindToken { + Purchase, + Upgrade, +} + +impl ProfileMembershipChangeKindToken { + pub fn as_str(&self) -> &'static str { + match self { + Self::Purchase => "purchase", + Self::Upgrade => "upgrade", + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileRechargeProductResponse { @@ -264,14 +341,14 @@ pub struct ProfileRechargeProductResponse { #[serde(rename_all = "camelCase")] pub struct ProfileMembershipPlanResponse { /// 档位 token:`normal` / `starter` / `plus` / `pro` / `max`。 - pub plan: String, + pub plan: ProfileMembershipPlanToken, pub title: String, pub rank: u8, pub month_price_cents: u64, pub year_price_cents: u64, pub period_points: u64, /// 模型权限 token:`basic` / `full`。 - pub model_access: String, + pub model_access: ProfileMembershipModelAccessToken, /// 并发上限;`128` 表示不设上限。 pub concurrent_job_limit: u32, /// 是否为运营推荐档位;由后端目录决定,前端不再硬编码档位 token。 @@ -286,7 +363,7 @@ pub struct ProfileMembershipPlanResponse { pub struct ProfileMembershipResponse { pub status: String, /// 当前档位 token;非会员与已到期都是 `normal`。 - pub plan: String, + pub plan: ProfileMembershipPlanToken, pub started_at: Option, pub expires_at: Option, pub updated_at: Option, @@ -299,7 +376,7 @@ pub struct ProfileMembershipResponse { /// 本次购买总期数(月付 1 / 年付 12)。 pub cycle_count: u32, /// 周期 token:`monthly` / `yearly`。 - pub cycle_kind: String, + pub cycle_kind: ProfileMembershipCycleKindToken, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -687,17 +764,17 @@ pub struct AdminUpsertProfileRechargeProductRequest { #[serde(rename_all = "camelCase")] pub struct ProfileMembershipUpgradeQuoteRequest { /// 目标档位 token:`starter` / `plus` / `pro` / `max`。 - pub target_plan: String, + pub target_plan: ProfileMembershipPlanToken, } /// 补差升级报价:付款前展示应付金额、补点与变更后的月度余额;下单时后端会用同一函数重算。 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileMembershipUpgradeQuoteResponse { - pub current_plan: String, - pub target_plan: String, + pub current_plan: ProfileMembershipPlanToken, + pub target_plan: ProfileMembershipPlanToken, /// 周期 token:`monthly` / `yearly`;有效期内不支持月年互换,所以变更前后一致。 - pub cycle_kind: String, + pub cycle_kind: ProfileMembershipCycleKindToken, pub cycle_index: u32, pub cycle_count: u32, /// 应付金额(分);月付 = 月价差,年付 = 年价差 ÷ 12 × (后续完整月数 + 本期剩余比例),向上取整到分。 @@ -1673,7 +1750,7 @@ mod tests { }, membership: ProfileMembershipResponse { status: "active".to_string(), - plan: "plus".to_string(), + plan: ProfileMembershipPlanToken::Plus, started_at: Some("2026-04-25T10:00:00Z".to_string()), expires_at: Some("2026-05-25T10:00:00Z".to_string()), updated_at: Some("2026-04-25T10:00:00Z".to_string()), @@ -1683,7 +1760,7 @@ mod tests { cycle_remaining_points: 320, cycle_index: 1, cycle_count: 1, - cycle_kind: "monthly".to_string(), + cycle_kind: ProfileMembershipCycleKindToken::Monthly, }, point_products: vec![ProfileRechargeProductResponse { product_id: "points_60".to_string(), @@ -1697,13 +1774,13 @@ mod tests { description: "60泥点".to_string(), }], membership_plans: vec![ProfileMembershipPlanResponse { - plan: "plus".to_string(), + plan: ProfileMembershipPlanToken::Plus, title: "Plus".to_string(), rank: 2, month_price_cents: 9_900, year_price_cents: 99_000, period_points: 1_150, - model_access: "full".to_string(), + model_access: ProfileMembershipModelAccessToken::Full, concurrent_job_limit: 3, recommended: true, unlimited_concurrency: false, @@ -1783,7 +1860,7 @@ mod tests { mud_point_balance: empty_mud_point_balance(), membership: ProfileMembershipResponse { status: "normal".to_string(), - plan: "normal".to_string(), + plan: ProfileMembershipPlanToken::Normal, started_at: None, expires_at: None, updated_at: None, @@ -1793,7 +1870,7 @@ mod tests { cycle_remaining_points: 0, cycle_index: 1, cycle_count: 1, - cycle_kind: "monthly".to_string(), + cycle_kind: ProfileMembershipCycleKindToken::Monthly, }, point_products: vec![], membership_plans: vec![], @@ -1859,7 +1936,7 @@ mod tests { mud_point_balance: empty_mud_point_balance(), membership: ProfileMembershipResponse { status: "normal".to_string(), - plan: "normal".to_string(), + plan: ProfileMembershipPlanToken::Normal, started_at: None, expires_at: None, updated_at: None, @@ -1869,7 +1946,7 @@ mod tests { cycle_remaining_points: 0, cycle_index: 1, cycle_count: 1, - cycle_kind: "monthly".to_string(), + cycle_kind: ProfileMembershipCycleKindToken::Monthly, }, point_products: vec![], membership_plans: vec![], diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs index 311352f13..676c6e721 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs @@ -2019,6 +2019,19 @@ pub(crate) fn map_runtime_profile_membership_cycle_kind_back( } } +pub(crate) fn map_runtime_profile_membership_change_kind_back( + value: crate::module_bindings::RuntimeProfileMembershipChangeKind, +) -> module_runtime::RuntimeProfileMembershipChangeKind { + match value { + crate::module_bindings::RuntimeProfileMembershipChangeKind::Purchase => { + module_runtime::RuntimeProfileMembershipChangeKind::Purchase + } + crate::module_bindings::RuntimeProfileMembershipChangeKind::Upgrade => { + module_runtime::RuntimeProfileMembershipChangeKind::Upgrade + } + } +} + pub(crate) fn map_runtime_profile_membership_model_access( value: module_runtime::RuntimeProfileMembershipModelAccess, ) -> crate::module_bindings::RuntimeProfileMembershipModelAccess { @@ -2049,11 +2062,19 @@ pub(crate) fn map_runtime_profile_membership_order_change_snapshot( snapshot: RuntimeProfileMembershipOrderChangeSnapshot, ) -> module_runtime::RuntimeProfileMembershipOrderChangeSnapshot { module_runtime::RuntimeProfileMembershipOrderChangeSnapshot { - change_kind: snapshot.change_kind, - plan_before: snapshot.plan_before, - plan_after: snapshot.plan_after, - cycle_kind_before: snapshot.cycle_kind_before, - cycle_kind_after: snapshot.cycle_kind_after, + change_kind: map_runtime_profile_membership_change_kind_back(snapshot.change_kind), + plan_before: snapshot + .plan_before + .map(map_runtime_profile_membership_plan_back), + plan_after: snapshot + .plan_after + .map(map_runtime_profile_membership_plan_back), + cycle_kind_before: snapshot + .cycle_kind_before + .map(map_runtime_profile_membership_cycle_kind_back), + cycle_kind_after: snapshot + .cycle_kind_after + .map(map_runtime_profile_membership_cycle_kind_back), period_points_before: snapshot.period_points_before, period_points_after: snapshot.period_points_after, granted_points_delta: snapshot.granted_points_delta, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_order_change_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_order_change_snapshot_type.rs index 4e2f023a7..fde2b7c43 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_order_change_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_order_change_snapshot_type.rs @@ -4,14 +4,18 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_membership_change_kind_type::RuntimeProfileMembershipChangeKind; +use super::runtime_profile_membership_cycle_kind_type::RuntimeProfileMembershipCycleKind; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct RuntimeProfileMembershipOrderChangeSnapshot { - pub change_kind: String, - pub plan_before: Option, - pub plan_after: Option, - pub cycle_kind_before: Option, - pub cycle_kind_after: Option, + pub change_kind: RuntimeProfileMembershipChangeKind, + pub plan_before: Option, + pub plan_after: Option, + pub cycle_kind_before: Option, + pub cycle_kind_after: Option, pub period_points_before: Option, pub period_points_after: Option, pub granted_points_delta: u64, 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 a0e4b35d8..303530227 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -5207,19 +5207,11 @@ fn build_profile_membership_change_snapshot_from_order_row( ) -> Option { let change_kind = row.membership_change_kind?; Some(RuntimeProfileMembershipOrderChangeSnapshot { - change_kind: change_kind.as_str().to_string(), - plan_before: row - .membership_plan_before - .map(|plan| plan.as_str().to_string()), - plan_after: row - .membership_plan_after - .map(|plan| plan.as_str().to_string()), - cycle_kind_before: row - .membership_cycle_kind_before - .map(|kind| kind.as_str().to_string()), - cycle_kind_after: row - .membership_cycle_kind_after - .map(|kind| kind.as_str().to_string()), + change_kind, + plan_before: row.membership_plan_before, + plan_after: row.membership_plan_after, + cycle_kind_before: row.membership_cycle_kind_before, + cycle_kind_after: row.membership_cycle_kind_after, period_points_before: row.membership_period_points_before, period_points_after: row.membership_period_points_after, granted_points_delta: row.membership_granted_points_delta, From d7dec9b47515405712ebf84670b62f53a492ff62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 11:46:08 +0800 Subject: [PATCH 128/232] =?UTF-8?q?=E6=9E=9A=E4=B8=BE=E5=8C=96(=E4=BC=9A?= =?UTF-8?q?=E5=91=98=E5=90=8E=E5=8F=B0)=EF=BC=9A=E6=A1=A3=E4=BD=8D?= =?UTF-8?q?=E4=B8=8E=E6=A8=A1=E5=9E=8B=E6=9D=83=E9=99=90=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E6=94=B9=E7=94=A8=20typed=20enum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminUpsertProfileMembershipPlanRequest.plan/model_access 由 String 改为 token 枚举 - 未登记的档位 / 权限 token 在反序列化阶段直接拒绝,后台不能再持久化任意字符串 - admin handler 去掉字符串解析分支,改为 From 转换 - 删除仅用于手写解析的 parse_runtime_profile_membership_* 辅助函数 --- .../crates/api-server/src/runtime_profile.rs | 32 ++----------------- .../crates/shared-contracts/src/runtime.rs | 4 +-- 2 files changed, 5 insertions(+), 31 deletions(-) diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index def489579..c4d22d258 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1188,19 +1188,8 @@ pub async fn admin_upsert_profile_membership_plan( Extension(admin): Extension, Json(payload): Json, ) -> Result, Response> { - let plan = parse_runtime_profile_membership_plan(&payload.plan).map_err(|error| { - runtime_profile_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), - ) - })?; - let model_access = parse_runtime_profile_membership_model_access(&payload.model_access) - .map_err(|error| { - runtime_profile_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), - ) - })?; + let plan = payload.plan.into(); + let model_access = payload.model_access.into(); let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; let record = state .spacetime_client() @@ -2434,22 +2423,7 @@ fn parse_profile_recharge_product_kind( } } -/// 会员档位 token → 枚举。JSON 没有枚举,但解析必须严格:未知 token 一律 400。 -fn parse_runtime_profile_membership_plan( - raw: &str, -) -> Result { - module_runtime::RuntimeProfileMembershipPlan::parse(raw) - .ok_or_else(|| "会员档位无效".to_string()) -} - -/// 模型权限 token → 枚举;`basic` = 仅基础模型,`full` = 基础及高性能模型。 -fn parse_runtime_profile_membership_model_access( - raw: &str, -) -> Result { - module_runtime::RuntimeProfileMembershipModelAccess::parse(raw) - .ok_or_else(|| "模型权限无效".to_string()) -} - +/// 会员档位 / 模型权限 / 周期改由 typed enum 在反序列化阶段校验,未知 token 直接 4xx。 fn parse_tracking_scope_kind(raw: &str) -> Result { match raw.trim().to_ascii_lowercase().as_str() { TRACKING_SCOPE_KIND_SITE => Ok(RuntimeTrackingScopeKind::Site), diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index bab42b467..fc576d464 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -801,13 +801,13 @@ pub struct ProfileMembershipPlanAdminListResponse { #[serde(rename_all = "camelCase")] pub struct AdminUpsertProfileMembershipPlanRequest { /// 档位 token,作为主键即档位身份。 - pub plan: String, + pub plan: ProfileMembershipPlanToken, pub title: String, pub month_price_cents: u64, pub year_price_cents: u64, pub period_points: u64, /// 模型权限 token:`basic` / `full`;本期只落字段、不做服务端拦截。 - pub model_access: String, + pub model_access: ProfileMembershipModelAccessToken, /// 并发上限;`128` 表示不设上限。 pub concurrent_job_limit: u32, #[serde(default = "default_true")] From 75766824f6ae1939ffea456104f3b115363b5242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 11:59:05 +0800 Subject: [PATCH 129/232] =?UTF-8?q?=E6=96=87=E6=A1=A3(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9)=EF=BC=9A=E5=90=8C=E6=AD=A5=20granted=5Fpoin?= =?UTF-8?q?ts=5Fdelta=20=E6=94=B9=20u64=20=E4=B8=8E=20wire=20token=20?= =?UTF-8?q?=E6=9E=9A=E4=B8=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - membership_granted_points_delta 列类型文档由 i64 更新为 u64 - 补充 shared-contracts 新增四个会员 wire token 枚举及其与模块枚举的映射说明 --- .../【技术设计】泥点三池与会员计费后端设计-2026-10-02.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index d837d45b2..20e29c7ad 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -133,7 +133,7 @@ pub struct ProfileMembershipPlan { | `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_granted_points_delta` | `u64` | `0` | 本次补点(进月度池的点数);与报价侧 `ProfileMembershipUpgradeQuoteResponse.granted_points_delta` 一致 | | `membership_cycle_index` | `Option` | `None` | 变更时所在期数 | | `membership_expires_at_before` | `Option` | `None` | 变更前到期 | | `membership_expires_at_after` | `Option` | `None` | 变更后到期 | @@ -150,6 +150,10 @@ pub struct ProfileMembershipPlan { - 新增 `RuntimeProfileMembershipCycleKind { Monthly, Yearly }`(全新,无历史数据)。 - 新增 `RuntimeProfileMembershipModelAccess { Basic, Full }`(仅新增字段,本期不执行)。 - 新增 `RuntimeProfileMembershipChangeKind { Purchase, Upgrade }`(订单快照用,替代字符串)。 +- `shared-contracts` 新增 wire 侧 `ProfileMembershipPlanToken` / `ProfileMembershipCycleKindToken` / + `ProfileMembershipModelAccessToken` / `ProfileMembershipChangeKindToken`,把 DTO 里原来的裸 + `String`(后台 upsert 请求、订单变更快照、会员 / 档位 / 报价响应)收口为 typed enum; + 与模块枚举通过 `From` 双向映射,JSON wire 值仍是小写 token,未知 token 在反序列化阶段被拒。 - **删除** `RuntimeProfileMembershipTier { Normal, Month, Season, Year, Starter, Basic, Pro, Ultimate }` 及其 `as_str()`(会员侧零存量,用户已授权破坏性变更)。 - `RuntimeProfileWalletLedgerSourceType` **末尾追加** `MembershipUpgradeGrant` From 68c8544a15272229348a26889787c411ff0891f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 12:41:26 +0800 Subject: [PATCH 130/232] =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=8C=96(=E4=BC=9A?= =?UTF-8?q?=E5=91=98=E9=94=99=E8=AF=AF)=EF=BC=9A=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E6=8B=92=E7=BB=9D=E6=94=B9=E7=94=A8=E6=9C=BA?= =?UTF-8?q?=E5=99=A8=E5=8F=AF=E8=AF=BB=20error=5Fcode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module-runtime 新增 RuntimeProfileMembershipErrorCode 与 RuntimeProfileMembershipDomainError - 会员报价 / 充值中心 / 后台档位 upsert 的 procedure 结果末尾追加 error_code - spacetime-module 在拒绝点显式赋码,中文 error_message 逐字保持不变 - spacetime-client 新增 SpacetimeClientError::ProcedureRejected 并映射生成绑定 - api-server 删除会员中文前缀分类,改按 error_code 返回 400,基础设施错误仍 502 - 重生成 module_bindings,并同步设计文档 5.8 与决策日志 --- .../shared-memory/decision-log.md | 9 + ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 18 + .../crates/api-server/src/asset_billing.rs | 2 + .../crates/api-server/src/editor_project.rs | 2 + .../crates/api-server/src/runtime_profile.rs | 97 +++-- server-rs/crates/module-runtime/src/domain.rs | 79 ++++ .../crates/spacetime-client/src/active.rs | 16 + .../src/active/mapper/runtime_profile.rs | 74 +++- .../spacetime-client/src/module_bindings.rs | 2 + ...time_profile_membership_error_code_type.rs | 36 ++ ...ership_plan_admin_procedure_result_type.rs | 2 + ...hip_upgrade_quote_procedure_result_type.rs | 2 + ...e_recharge_center_procedure_result_type.rs | 2 + .../src/runtime/active/profile.rs | 403 +++++++++++++----- 14 files changed, 601 insertions(+), 143 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_error_code_type.rs diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9f35ea21c..448e75896 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9585,3 +9585,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响范围:`server-rs/crates/shared-contracts/src/admin.rs`、`server-rs/crates/api-server/src/admin_recharge.rs`、`apps/admin-web/src/{api/adminApiTypes.ts,config/membershipDisplay.ts,pages/AdminMembershipPlanPage.tsx,pages/AdminRechargeOrderPage.tsx,components/AdminUserDetailDialog.tsx}`、`scripts/admin-web-fake-api.mjs`、设计文档 §5.6/§10.2、本文件。 - 验证:`cargo check -p api-server`;`npm run admin-web:typecheck`;`npx vitest run apps/admin-web` → 26 files / 248 tests passed(新增「会员订单在发放泥点列展示会员变更快照」及用户详情会员断言)。 - 边界:`/admin/api/*` 不在 `/api/external/v1` OpenAPI 门禁内,本次未补接口级契约测试,会员快照只在管理端页面用例层面取证。 + +## 2026-10-03 会员链路错误分类改为机器可读错误码(typed error) + +- 背景:`api-server/src/runtime_profile.rs` 的 `is_runtime_profile_membership_domain_error` 用中文 `error_message` 前缀 / 精确匹配决定返回 400 还是 502;文案一改(或新增拒绝点)就静默退化,且分类逻辑与 module 的错误文案跨层重复。 +- 决策(错误码随 procedure 结果透传):`module-runtime` 新增 `RuntimeProfileMembershipErrorCode`(`UpgradeRejected` / `NotMember` / `AlreadyActive` / `CycleKindLocked` / `MembershipExpired` / `StateChanged` / `PlanNotPurchasable` / `PlanCatalogMissing` / `ProductIdUnparsable` / `MembershipProductRetired` / `InvalidPlanConfig`)与内部 `RuntimeProfileMembershipDomainError { code, message }`;三个会员 procedure 结果(升级报价、充值中心 / 下单、后台档位 upsert)末尾追加 `error_code: Option<...>`,拒绝点显式赋码,不再做字符串分类。 +- 决策(客户端错误类型):`spacetime-client` 新增 `SpacetimeClientError::ProcedureRejected { code, message }`;`api-server` 据此返回 400(provider `runtime-profile`),基础设施错误保持 502。字段校验类错误不产码,仍落 502。 +- 决策(行为变化):`会员目录缺少档位 ...` 与「已迁移的会员商品」原先落 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` 通过。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index 20e29c7ad..9b6af65ab 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -433,6 +433,24 @@ flowchart TD - `npm run check:generated-bindings` 以「重新生成并与工作区逐字节比较」做门禁,防止 Rust 改了而生成文件没重跑。 - 需要展示用派生字段(如 `totalPoints` 合计)时,在前端 `utils/mudPoints.ts` 里派生,不改 DTO。 +### 5.8 会员错误码契约(typed error,已落地) + +- 会员链路的业务拒绝不再靠中文 `error_message` 前缀分类。`module-runtime` 新增 + `RuntimeProfileMembershipErrorCode`(`UpgradeRejected` / `NotMember` / `AlreadyActive` / + `CycleKindLocked` / `MembershipExpired` / `StateChanged` / `PlanNotPurchasable` / + `PlanCatalogMissing` / `ProductIdUnparsable` / `MembershipProductRetired` / `InvalidPlanConfig`), + 在拒绝点显式产出,`RuntimeProfileMembershipUpgradeQuoteProcedureResult` / + `RuntimeProfileRechargeCenterProcedureResult` / `RuntimeProfileMembershipPlanAdminProcedureResult` + 末尾追加 `error_code: Option`(`error_message` 保留人读文案)。 +- `spacetime-client` 的 `SpacetimeClientError` 新增 + `ProcedureRejected { code, message }`;`api-server` 的 `runtime_profile` 映射据此返回 400 + (`provider = runtime-profile`),基础设施错误保持 502。字段校验类错误(如 user_id 缺失) + 不产出 `error_code`,仍按字符串错误落到 502。 +- 行为变化:`会员目录缺少档位 ...` 与「已迁移的会员商品」原先落到 502,现在有明确错误码,统一 400。 +- 兼容边界:procedure result 是 wire 结构,新增字段要求 module 与 api-server 同版本部署; + 旧 module 不返回 `error_code` 时,新 client 绑定反序列化会失败。兑换码链路本轮未改造, + `api-server` 仍保留一个兑换码文案匹配函数。 + --- ## 6. 计算规则(后端唯一真相源) diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index 9ba5795db..0b37b9980 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -695,6 +695,8 @@ fn should_use_wallet_refund_emergency_spool(error: &SpacetimeClientError) -> boo || message.contains("连接已断开") || message.contains("连接在返回结果前已断开") } + // 业务拒绝是服务端已明确应答,不应触发退款应急 spool。 + SpacetimeClientError::ProcedureRejected { .. } => false, } } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index db2e78e56..602771e28 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1149,6 +1149,7 @@ fn is_editor_generation_retryable_transport_error(error: &SpacetimeClientError) || message.contains("连接在返回结果前已断开") } SpacetimeClientError::Procedure(_) => false, + SpacetimeClientError::ProcedureRejected { .. } => false, } } @@ -1160,6 +1161,7 @@ fn is_editor_generation_unknown_result_error( && !matches!( error, SpacetimeClientError::Procedure(_) + | SpacetimeClientError::ProcedureRejected { .. } | SpacetimeClientError::Timeout( SpacetimeClientStage::PoolAcquire | SpacetimeClientStage::ConnectBuild diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index c4d22d258..acd944add 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1463,13 +1463,12 @@ pub async fn get_profile_play_stats( } fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError { - let is_domain_error = matches!( - &error, - SpacetimeClientError::Procedure(message) - if is_runtime_profile_domain_error(message) - ); + let is_domain_error = is_runtime_profile_domain_error(&error); let (status, provider) = match &error { SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-profile"), + SpacetimeClientError::ProcedureRejected { .. } => { + (StatusCode::BAD_REQUEST, "runtime-profile") + } SpacetimeClientError::Procedure(_) if is_domain_error => { (StatusCode::BAD_REQUEST, "runtime-profile") } @@ -1487,24 +1486,18 @@ fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError { } /// 模块侧的可读业务拒绝以 400 返回,基础设施错误保持 502。 -fn is_runtime_profile_domain_error(message: &str) -> bool { - is_runtime_profile_redeem_code_domain_error(message) - || is_runtime_profile_membership_domain_error(message) -} - -fn is_runtime_profile_membership_domain_error(message: &str) -> bool { - message.starts_with("会员升级被拒绝: ") - || message.starts_with("会员档位") - || message.starts_with("会员商品 product_id 无法解析: ") - || message.starts_with("充值商品类型无效:会员商品已迁移到会员档位目录") - || matches!( - message, - "当前不是会员,请直接选择套餐购买" - | "当前套餐已生效,无需重复购买" - | "当前会员有效期内不支持月付 / 年付转换,到期后可重新选择" - | "会员已到期,请按新购价格重新下单" - | "会员状态已变化,请重新下单" - ) +/// +/// 会员链路的拒绝已由 SpacetimeDB procedure 透传机器可读错误码,走 +/// [`SpacetimeClientError::ProcedureRejected`];兑换码尚未改造,仍按可读文案匹配(见 +/// [`is_runtime_profile_redeem_code_domain_error`])。 +fn is_runtime_profile_domain_error(error: &SpacetimeClientError) -> bool { + match error { + SpacetimeClientError::ProcedureRejected { .. } => true, + SpacetimeClientError::Procedure(message) => { + is_runtime_profile_redeem_code_domain_error(message) + } + _ => false, + } } fn is_runtime_profile_redeem_code_domain_error(message: &str) -> bool { @@ -2534,12 +2527,12 @@ mod tests { use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL, RuntimeProfileDailyFreePointsRecord, RuntimeProfileMembershipCycleKind, - RuntimeProfileMembershipModelAccess, RuntimeProfileMembershipPlan, - RuntimeProfileMembershipPlanRecord, RuntimeProfileMembershipRecord, - RuntimeProfileMembershipStatus, RuntimeProfileRechargeCenterRecord, - RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, - RuntimeProfileRechargeProductKind, RuntimeProfileRechargeProductRecord, - RuntimeProfileWalletLedgerSourceType, + RuntimeProfileMembershipErrorCode, RuntimeProfileMembershipModelAccess, + RuntimeProfileMembershipPlan, RuntimeProfileMembershipPlanRecord, + RuntimeProfileMembershipRecord, RuntimeProfileMembershipStatus, + RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, + RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductKind, + RuntimeProfileRechargeProductRecord, RuntimeProfileWalletLedgerSourceType, }; use super::{ @@ -2587,20 +2580,48 @@ mod tests { #[test] fn membership_business_rejections_map_to_bad_request() { - // 中文注释:会员商品迁档位目录后的业务拒绝必须按 400 返回;漏掉前缀匹配会落到 502, - // 让后台与下单端把可读的业务提示当成基础设施故障。目录缺行属于部署配置错误,保持 502。 - for message in [ - "会员升级被拒绝: cycle_kind_change_unsupported", - "会员商品 product_id 无法解析: membership-pro-yearly", - "充值商品类型无效:会员商品已迁移到会员档位目录", + // 中文注释:会员业务拒绝由 procedure 透传机器可读 error_code,统一按 400 返回; + // 中文文案不再参与分类,普通 Procedure 字符串错误保持 502。 + for (code, message) in [ + ( + RuntimeProfileMembershipErrorCode::UpgradeRejected, + "会员升级被拒绝: cycle_kind_change_unsupported", + ), + ( + RuntimeProfileMembershipErrorCode::ProductIdUnparsable, + "会员商品 product_id 无法解析: membership-pro-yearly", + ), + ( + RuntimeProfileMembershipErrorCode::MembershipProductRetired, + "充值商品类型无效:会员商品已迁移到会员档位目录", + ), + ( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + "会员目录缺少档位 pro", + ), ] { - let error = map_runtime_profile_client_error(SpacetimeClientError::Procedure( - message.to_string(), - )); + let error = map_runtime_profile_client_error(SpacetimeClientError::ProcedureRejected { + code, + message: message.to_string(), + }); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST, "{message}"); assert_eq!(error.message(), message); + assert_eq!( + error + .details() + .and_then(|details| details.get("provider")) + .and_then(Value::as_str), + Some("runtime-profile") + ); } + + // 中文注释:删掉字符串前缀分类后,同样的文案若没有 error_code 必须落到 502。 + let legacy_string_error = + map_runtime_profile_client_error(SpacetimeClientError::Procedure( + "会员升级被拒绝: cycle_kind_change_unsupported".to_string(), + )); + assert_eq!(legacy_string_error.status_code(), StatusCode::BAD_GATEWAY); } #[test] diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 55e978e73..94860d76b 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1467,6 +1467,82 @@ impl From } } +/// 会员业务拒绝的机器可读错误码。 +/// +/// 会员链路的人读 `error_message` 会随文案调整,调用方不得再按中文字符串判断 HTTP 状态; +/// 每个拒绝点显式产出本枚举,随 procedure 结果透传到 Axum 侧。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeProfileMembershipErrorCode { + /// 补差升级报价 / 下单被升级规则拒绝。 + UpgradeRejected, + /// 当前不是会员,应直接购买套餐。 + NotMember, + /// 当前套餐已生效,无需重复购买。 + AlreadyActive, + /// 有效期内不支持月付 / 年付转换。 + CycleKindLocked, + /// 会员已到期,需按新购价格重新下单。 + MembershipExpired, + /// 下单后会员状态已变化,需要重新下单。 + StateChanged, + /// 目标档位当前不可购买。 + PlanNotPurchasable, + /// 代码内档位目录缺少该档位。 + PlanCatalogMissing, + /// 会员商品 product_id 无法解析。 + ProductIdUnparsable, + /// 会员商品已迁移到档位目录,旧商品不再可用。 + MembershipProductRetired, + /// 后台会员档位改价 / 改权益配置校验失败。 + InvalidPlanConfig, +} + +impl RuntimeProfileMembershipErrorCode { + pub fn as_str(&self) -> &'static str { + match self { + Self::UpgradeRejected => "upgrade_rejected", + Self::NotMember => "not_member", + Self::AlreadyActive => "already_active", + Self::CycleKindLocked => "cycle_kind_locked", + Self::MembershipExpired => "membership_expired", + Self::StateChanged => "state_changed", + Self::PlanNotPurchasable => "plan_not_purchasable", + Self::PlanCatalogMissing => "plan_catalog_missing", + Self::ProductIdUnparsable => "product_id_unparsable", + Self::MembershipProductRetired => "membership_product_retired", + Self::InvalidPlanConfig => "invalid_plan_config", + } + } +} + +/// 会员领域错误:机器可读 `code` + 人读 `message`。 +/// +/// 只在模块内部与 procedure 结果装载之间传递,不落 SpacetimeDB 表,因此不派生 `SpacetimeType`。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeProfileMembershipDomainError { + pub code: RuntimeProfileMembershipErrorCode, + pub message: String, +} + +impl RuntimeProfileMembershipDomainError { + pub fn new(code: RuntimeProfileMembershipErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } +} + +impl std::fmt::Display for RuntimeProfileMembershipDomainError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for RuntimeProfileMembershipDomainError {} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimeProfileRechargeOrderStatus { @@ -1888,6 +1964,7 @@ pub struct RuntimeProfileRechargeCenterProcedureResult { pub record: Option, pub order: Option, pub error_message: Option, + pub error_code: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -1969,6 +2046,7 @@ pub struct RuntimeProfileMembershipPlanAdminProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + pub error_code: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -2019,6 +2097,7 @@ pub struct RuntimeProfileMembershipUpgradeQuoteProcedureResult { pub ok: bool, pub quote: Option, pub error_message: Option, + pub error_code: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index 809e881df..1d4dc086c 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -233,6 +233,11 @@ pub enum SpacetimeClientError { Build(String), ConnectDropped, Procedure(String), + /// 会员 procedure 返回了带机器可读错误码的业务拒绝。 + ProcedureRejected { + code: module_runtime::RuntimeProfileMembershipErrorCode, + message: String, + }, Runtime(String), Timeout(SpacetimeClientStage), } @@ -991,6 +996,16 @@ impl SpacetimeClientError { Self::Procedure(message.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string())) } + pub(crate) fn procedure_rejected( + code: module_runtime::RuntimeProfileMembershipErrorCode, + message: Option, + ) -> Self { + Self::ProcedureRejected { + code, + message: message.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + } + } + pub(crate) fn missing_snapshot(label: &'static str) -> Self { Self::Procedure(format!("SpacetimeDB procedure 未返回{label}")) } @@ -1073,6 +1088,7 @@ impl fmt::Display for SpacetimeClientError { Self::Build(message) | Self::Procedure(message) | Self::Runtime(message) => { f.write_str(message) } + Self::ProcedureRejected { message, .. } => f.write_str(message), Self::ConnectDropped => f.write_str("SpacetimeDB 连接在返回结果前已断开"), Self::Timeout(stage) => { f.write_str(&format!("SpacetimeDB {} 调用超时", stage.as_str())) diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs index 676c6e721..73a65cdc2 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs @@ -1,5 +1,19 @@ use super::*; +/// 会员 procedure 失败统一落错误:有机器可读 `error_code` 时走 `ProcedureRejected`,否则回退字符串错误。 +pub(crate) fn map_runtime_profile_membership_procedure_failure( + error_code: Option, + error_message: Option, +) -> SpacetimeClientError { + match error_code { + Some(code) => SpacetimeClientError::procedure_rejected( + map_runtime_profile_membership_error_code_back(code), + error_message, + ), + None => SpacetimeClientError::procedure_failed(error_message), + } +} + impl From for RuntimeProfileDashboardGetInput { fn from(input: module_runtime::RuntimeProfileDashboardGetInput) -> Self { Self { @@ -736,7 +750,10 @@ pub(crate) fn map_runtime_profile_recharge_center_procedure_result( result: RuntimeProfileRechargeCenterProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); + return Err(map_runtime_profile_membership_procedure_failure( + result.error_code, + result.error_message, + )); } let snapshot = result @@ -772,7 +789,10 @@ pub(crate) fn map_runtime_profile_recharge_order_procedure_result( SpacetimeClientError, > { if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); + return Err(map_runtime_profile_membership_procedure_failure( + result.error_code, + result.error_message, + )); } let center = result @@ -2032,6 +2052,46 @@ pub(crate) fn map_runtime_profile_membership_change_kind_back( } } +pub(crate) fn map_runtime_profile_membership_error_code_back( + value: crate::module_bindings::RuntimeProfileMembershipErrorCode, +) -> module_runtime::RuntimeProfileMembershipErrorCode { + match value { + crate::module_bindings::RuntimeProfileMembershipErrorCode::UpgradeRejected => { + module_runtime::RuntimeProfileMembershipErrorCode::UpgradeRejected + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::NotMember => { + module_runtime::RuntimeProfileMembershipErrorCode::NotMember + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::AlreadyActive => { + module_runtime::RuntimeProfileMembershipErrorCode::AlreadyActive + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::CycleKindLocked => { + module_runtime::RuntimeProfileMembershipErrorCode::CycleKindLocked + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::MembershipExpired => { + module_runtime::RuntimeProfileMembershipErrorCode::MembershipExpired + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::StateChanged => { + module_runtime::RuntimeProfileMembershipErrorCode::StateChanged + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::PlanNotPurchasable => { + module_runtime::RuntimeProfileMembershipErrorCode::PlanNotPurchasable + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::PlanCatalogMissing => { + module_runtime::RuntimeProfileMembershipErrorCode::PlanCatalogMissing + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::ProductIdUnparsable => { + module_runtime::RuntimeProfileMembershipErrorCode::ProductIdUnparsable + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::MembershipProductRetired => { + module_runtime::RuntimeProfileMembershipErrorCode::MembershipProductRetired + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::InvalidPlanConfig => { + module_runtime::RuntimeProfileMembershipErrorCode::InvalidPlanConfig + } + } +} + pub(crate) fn map_runtime_profile_membership_model_access( value: module_runtime::RuntimeProfileMembershipModelAccess, ) -> crate::module_bindings::RuntimeProfileMembershipModelAccess { @@ -2131,7 +2191,10 @@ pub(crate) fn map_runtime_profile_membership_upgrade_quote_procedure_result( result: RuntimeProfileMembershipUpgradeQuoteProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); + return Err(map_runtime_profile_membership_procedure_failure( + result.error_code, + result.error_message, + )); } let quote = result @@ -2173,7 +2236,10 @@ pub(crate) fn map_runtime_profile_membership_plan_admin_procedure_result( result: RuntimeProfileMembershipPlanAdminProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); + return Err(map_runtime_profile_membership_procedure_failure( + result.error_code, + result.error_message, + )); } let record = result diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 928f87ec3..b865b2c03 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -705,6 +705,7 @@ pub mod runtime_profile_invite_code_admin_upsert_input_type; pub mod runtime_profile_invite_code_snapshot_type; pub mod runtime_profile_membership_change_kind_type; pub mod runtime_profile_membership_cycle_kind_type; +pub mod runtime_profile_membership_error_code_type; pub mod runtime_profile_membership_get_input_type; pub mod runtime_profile_membership_model_access_type; pub mod runtime_profile_membership_order_change_snapshot_type; @@ -1579,6 +1580,7 @@ pub use runtime_profile_invite_code_admin_upsert_input_type::RuntimeProfileInvit pub use runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot; pub use runtime_profile_membership_change_kind_type::RuntimeProfileMembershipChangeKind; pub use runtime_profile_membership_cycle_kind_type::RuntimeProfileMembershipCycleKind; +pub use runtime_profile_membership_error_code_type::RuntimeProfileMembershipErrorCode; pub use runtime_profile_membership_get_input_type::RuntimeProfileMembershipGetInput; pub use runtime_profile_membership_model_access_type::RuntimeProfileMembershipModelAccess; pub use runtime_profile_membership_order_change_snapshot_type::RuntimeProfileMembershipOrderChangeSnapshot; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_error_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_error_code_type.rs new file mode 100644 index 000000000..984004de4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_error_code_type.rs @@ -0,0 +1,36 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileMembershipErrorCode { + UpgradeRejected, + + NotMember, + + AlreadyActive, + + CycleKindLocked, + + MembershipExpired, + + StateChanged, + + PlanNotPurchasable, + + PlanCatalogMissing, + + ProductIdUnparsable, + + MembershipProductRetired, + + InvalidPlanConfig, +} + +impl __sdk::InModule for RuntimeProfileMembershipErrorCode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_procedure_result_type.rs index 78ee5407e..db0ada966 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_procedure_result_type.rs @@ -4,6 +4,7 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_membership_error_code_type::RuntimeProfileMembershipErrorCode; use super::runtime_profile_membership_plan_record_type::RuntimeProfileMembershipPlanRecord; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -12,6 +13,7 @@ pub struct RuntimeProfileMembershipPlanAdminProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileMembershipPlanAdminProcedureResult { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_procedure_result_type.rs index 213059e5e..37356951d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_procedure_result_type.rs @@ -4,6 +4,7 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_membership_error_code_type::RuntimeProfileMembershipErrorCode; use super::runtime_profile_membership_upgrade_quote_snapshot_type::RuntimeProfileMembershipUpgradeQuoteSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -12,6 +13,7 @@ pub struct RuntimeProfileMembershipUpgradeQuoteProcedureResult { pub ok: bool, pub quote: Option, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileMembershipUpgradeQuoteProcedureResult { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_procedure_result_type.rs index e9e055e02..202c1a1a1 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_procedure_result_type.rs @@ -4,6 +4,7 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_membership_error_code_type::RuntimeProfileMembershipErrorCode; use super::runtime_profile_recharge_center_snapshot_type::RuntimeProfileRechargeCenterSnapshot; use super::runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot; @@ -14,6 +15,7 @@ pub struct RuntimeProfileRechargeCenterProcedureResult { pub record: Option, pub order: Option, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileRechargeCenterProcedureResult { 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 303530227..32c9b84d3 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -22,6 +22,42 @@ const ASSET_OPERATION_REFUND_LEDGER_PREFIX: &str = "asset_operation_refund:"; const PROFILE_WALLET_REFUND_OUTBOX_STATUS_PENDING: &str = "pending"; const PROFILE_WALLET_REFUND_OUTBOX_MAX_BATCH_SIZE: u32 = 100; +/// 会员 / 充值 procedure 的错误载体。 +/// +/// 会员领域拒绝携带机器可读 [`RuntimeProfileMembershipErrorCode`];其它来源的错误(身份校验、 +/// 钱包溢出、订单不存在等)保持普通字符串,透传时 `error_code` 为 `null`。 +enum ProfileMembershipProcedureError { + Membership(RuntimeProfileMembershipDomainError), + Plain(String), +} + +impl From for ProfileMembershipProcedureError { + fn from(error: RuntimeProfileMembershipDomainError) -> Self { + Self::Membership(error) + } +} + +impl From for ProfileMembershipProcedureError { + fn from(message: String) -> Self { + Self::Plain(message) + } +} + +impl From<&str> for ProfileMembershipProcedureError { + fn from(message: &str) -> Self { + Self::Plain(message.to_string()) + } +} + +impl ProfileMembershipProcedureError { + fn into_parts(self) -> (String, Option) { + match self { + Self::Membership(error) => (error.message, Some(error.code)), + Self::Plain(message) => (message, None), + } + } +} + #[spacetimedb::table(accessor = profile_dashboard_state)] pub struct ProfileDashboardState { #[primary_key] @@ -1247,17 +1283,23 @@ pub fn admin_upsert_profile_membership_plan( tx, caller, )?; upsert_profile_membership_plan_record(tx, input.clone()) + .map_err(ProfileMembershipProcedureError::from) }) { Ok(record) => RuntimeProfileMembershipPlanAdminProcedureResult { ok: true, record: Some(record), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileMembershipPlanAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileMembershipPlanAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + error_code, + } + } } } @@ -1272,18 +1314,29 @@ pub fn get_profile_membership_upgrade_quote( crate::editor_project_storage::require_editor_generation_runtime_service_identity( tx, caller, )?; - build_profile_membership_upgrade_quote_record(tx, input.clone()) + let validated = build_runtime_profile_membership_upgrade_quote_input( + input.user_id.clone(), + input.target_plan, + ) + .map_err(|error| error.to_string())?; + build_profile_membership_upgrade_quote_record(tx, validated) + .map_err(ProfileMembershipProcedureError::from) }) { Ok(quote) => RuntimeProfileMembershipUpgradeQuoteProcedureResult { ok: true, quote: Some(quote), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileMembershipUpgradeQuoteProcedureResult { - ok: false, - quote: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileMembershipUpgradeQuoteProcedureResult { + ok: false, + quote: None, + error_message: Some(message), + error_code, + } + } } } @@ -1619,19 +1672,27 @@ pub fn get_profile_recharge_center( ctx: &mut ProcedureContext, input: RuntimeProfileRechargeCenterGetInput, ) -> RuntimeProfileRechargeCenterProcedureResult { - match ctx.try_with_tx(|tx| get_profile_recharge_center_snapshot(tx, input.clone())) { + match ctx.try_with_tx(|tx| { + get_profile_recharge_center_snapshot(tx, input.clone()) + .map_err(ProfileMembershipProcedureError::from) + }) { Ok(record) => RuntimeProfileRechargeCenterProcedureResult { ok: true, record: Some(record), order: None, error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRechargeCenterProcedureResult { + ok: false, + record: None, + order: None, + error_message: Some(message), + error_code, + } + } } } @@ -1671,19 +1732,27 @@ pub fn get_profile_recharge_order_and_return( ctx: &mut ProcedureContext, input: RuntimeProfileRechargeOrderGetInput, ) -> RuntimeProfileRechargeCenterProcedureResult { - match ctx.try_with_tx(|tx| get_profile_recharge_order_snapshot(tx, input.clone())) { + match ctx.try_with_tx(|tx| { + get_profile_recharge_order_snapshot(tx, input.clone()) + .map_err(ProfileMembershipProcedureError::from) + }) { Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult { ok: true, record: Some(record), order: Some(order), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRechargeCenterProcedureResult { + ok: false, + record: None, + order: None, + error_message: Some(message), + error_code, + } + } } } @@ -1698,13 +1767,18 @@ pub fn create_profile_recharge_order_and_return( record: Some(record), order: Some(order), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRechargeCenterProcedureResult { + ok: false, + record: None, + order: None, + error_message: Some(message), + error_code, + } + } } } @@ -1719,19 +1793,25 @@ pub fn mark_profile_recharge_order_paid_and_return( tx, caller, )?; mark_profile_recharge_order_paid_record(tx, input.clone()) + .map_err(ProfileMembershipProcedureError::from) }) { Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult { ok: true, record: Some(record), order: Some(order), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRechargeCenterProcedureResult { + ok: false, + record: None, + order: None, + error_message: Some(message), + error_code, + } + } } } @@ -2382,19 +2462,27 @@ pub fn close_profile_recharge_order_and_return( ctx: &mut ProcedureContext, input: RuntimeProfileRechargeOrderCloseInput, ) -> RuntimeProfileRechargeCenterProcedureResult { - match ctx.try_with_tx(|tx| close_profile_recharge_order_record(tx, input.clone())) { + match ctx.try_with_tx(|tx| { + close_profile_recharge_order_record(tx, input.clone()) + .map_err(ProfileMembershipProcedureError::from) + }) { Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult { ok: true, record: Some(record), order: Some(order), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRechargeCenterProcedureResult { + ok: false, + record: None, + order: None, + error_message: Some(message), + error_code, + } + } } } @@ -4893,7 +4981,7 @@ fn create_profile_recharge_order_record( RuntimeProfileRechargeCenterSnapshot, RuntimeProfileRechargeOrderSnapshot, ), - String, + ProfileMembershipProcedureError, > { let validated_input = build_runtime_profile_recharge_order_create_input( input.user_id, @@ -4902,7 +4990,19 @@ fn create_profile_recharge_order_record( ) .map_err(|error| error.to_string())?; let product = resolve_enabled_profile_recharge_order_product(ctx, &validated_input.product_id) - .ok_or_else(|| "recharge.product_id 不存在或已下架".to_string())?; + .ok_or_else(|| { + let message = "recharge.product_id 不存在或已下架"; + if parse_runtime_profile_membership_product_id(&validated_input.product_id).is_some() { + ProfileMembershipProcedureError::Membership( + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::MembershipProductRetired, + message, + ), + ) + } else { + ProfileMembershipProcedureError::Plain(message.to_string()) + } + })?; // 中文注释:建单时刻一律取服务端事务时间,procedure 入参不再携带时间; // 否则客户端可把时间设到会员到期前夕,压低升级补差金额。 let created_at_micros = ctx.timestamp.to_micros_since_unix_epoch(); @@ -5035,7 +5135,7 @@ fn mark_profile_recharge_order_paid_record( RuntimeProfileRechargeCenterSnapshot, RuntimeProfileRechargeOrderSnapshot, ), - String, + ProfileMembershipProcedureError, > { let validated_input = build_runtime_profile_recharge_order_paid_input( input.order_id, @@ -5068,7 +5168,9 @@ fn mark_profile_recharge_order_paid_record( order.status, RuntimeProfileRechargeOrderStatus::Pending | RuntimeProfileRechargeOrderStatus::Expired ) { - return Err("profile_recharge_order 当前状态不能确认支付".to_string()); + return Err(ProfileMembershipProcedureError::Plain( + "profile_recharge_order 当前状态不能确认支付".to_string(), + )); } let paid_at = Timestamp::from_micros_since_unix_epoch(validated_input.paid_at_micros); @@ -7155,7 +7257,7 @@ fn apply_profile_recharge_purchase( ctx: &ReducerContext, order: &ProfileRechargeOrder, paid_at: Timestamp, -) -> Result { +) -> Result { let user_id = order.user_id.as_str(); match order.kind { RuntimeProfileRechargeProductKind::Points => { @@ -8308,42 +8410,58 @@ fn list_profile_membership_plan_records( fn build_profile_membership_upgrade_quote_record( ctx: &ReducerContext, input: RuntimeProfileMembershipUpgradeQuoteGetInput, -) -> Result { - let validated = - build_runtime_profile_membership_upgrade_quote_input(input.user_id, input.target_plan) - .map_err(|error| error.to_string())?; - +) -> Result { let Some(row) = ctx .db .profile_membership() .user_id() - .find(&validated.user_id.to_string()) + .find(&input.user_id.to_string()) .filter(|row| active_membership_row_at(row, ctx.timestamp)) else { - return Err("当前不是会员,请直接选择套餐购买".to_string()); + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::NotMember, + "当前不是会员,请直接选择套餐购买", + )); }; // 只读投射账期到当前时刻:不落库、不发账本,避免预览就改会员 / 钱包状态。 let row = project_profile_membership_cycle_at(ctx, row, ctx.timestamp); - if row.plan == validated.target_plan { - return Err("当前套餐已生效,无需重复购买".to_string()); - } - let target = membership_plan_snapshot(ctx, validated.target_plan, row.cycle_kind) - .ok_or_else(|| format!("会员目录缺少档位 {}", validated.target_plan.as_str()))?; - if !target.enabled { - return Err(format!( - "会员档位 {} 当前不可购买", - validated.target_plan.as_str() + if row.plan == input.target_plan { + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::AlreadyActive, + "当前套餐已生效,无需重复购买", )); } - let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind) - .ok_or_else(|| format!("会员目录缺少档位 {}", row.plan.as_str()))?; + let target = + membership_plan_snapshot(ctx, input.target_plan, row.cycle_kind).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!("会员目录缺少档位 {}", input.target_plan.as_str()), + ) + })?; + if !target.enabled { + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanNotPurchasable, + format!("会员档位 {} 当前不可购买", input.target_plan.as_str()), + )); + } + let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!("会员目录缺少档位 {}", row.plan.as_str()), + ) + })?; let quote = quote_runtime_profile_membership_upgrade(membership_upgrade_quote_input( &row, ¤t, &target, ctx.timestamp, )) - .map_err(|rejection| format!("会员升级被拒绝: {}", rejection.as_str()))?; + .map_err(|rejection| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::UpgradeRejected, + format!("会员升级被拒绝: {}", rejection.as_str()), + ) + })?; Ok(RuntimeProfileMembershipUpgradeQuoteSnapshot { current_plan: current.plan, @@ -8364,7 +8482,7 @@ fn build_profile_membership_upgrade_quote_record( fn upsert_profile_membership_plan_record( ctx: &ReducerContext, input: RuntimeProfileMembershipPlanAdminUpsertInput, -) -> Result { +) -> Result { let validated = build_runtime_profile_membership_plan_admin_upsert_input( input.admin_user_id, input.plan, @@ -8390,9 +8508,12 @@ fn upsert_profile_membership_plan_record( // 缺失档位必须显式报错:回退成 rank 0 会与 Normal 占位同档,产生误导性的校验结果。 let rank = resolve_runtime_profile_membership_plan_row(validated.plan) .ok_or_else(|| { - format!( - "会员目录缺少档位 {},无法解析升级比较基准", - validated.plan.as_str() + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!( + "会员目录缺少档位 {},无法解析升级比较基准", + validated.plan.as_str() + ), ) })? .rank; @@ -8432,7 +8553,12 @@ fn upsert_profile_membership_plan_record( &candidate, &catalog_rows, ) - .map_err(|error| error.to_string())?; + .map_err(|error| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::InvalidPlanConfig, + error.to_string(), + ) + })?; if let Some(row) = existing { ctx.db.profile_membership_plan().plan().delete(&row.plan); @@ -9563,13 +9689,25 @@ fn build_profile_membership_order_snapshot( user_id: &str, product_id: &str, created_at: Timestamp, -) -> Result { - let (plan, cycle_kind) = parse_runtime_profile_membership_product_id(product_id) - .ok_or_else(|| format!("会员商品 product_id 无法解析: {product_id}"))?; - let target = membership_plan_snapshot(ctx, plan, cycle_kind) - .ok_or_else(|| format!("会员目录缺少档位 {}", plan.as_str()))?; +) -> Result { + let (plan, cycle_kind) = + parse_runtime_profile_membership_product_id(product_id).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::ProductIdUnparsable, + format!("会员商品 product_id 无法解析: {product_id}"), + ) + })?; + let target = membership_plan_snapshot(ctx, plan, cycle_kind).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!("会员目录缺少档位 {}", plan.as_str()), + ) + })?; if !target.enabled { - return Err(format!("会员档位 {} 当前不可购买", plan.as_str())); + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanNotPurchasable, + format!("会员档位 {} 当前不可购买", plan.as_str()), + )); } // 只读地把账期投射到建单时刻(不写库),避免用到过期账期导致金额与预览 / 结算不一致。 @@ -9602,17 +9740,32 @@ fn build_profile_membership_order_snapshot( }); }; if row.plan == plan { - return Err("当前套餐已生效,无需重复购买".to_string()); + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::AlreadyActive, + "当前套餐已生效,无需重复购买", + )); } if row.cycle_kind != cycle_kind { - return Err("当前会员有效期内不支持月付 / 年付转换,到期后可重新选择".to_string()); + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::CycleKindLocked, + "当前会员有效期内不支持月付 / 年付转换,到期后可重新选择", + )); } - let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind) - .ok_or_else(|| format!("会员目录缺少档位 {}", row.plan.as_str()))?; + let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!("会员目录缺少档位 {}", row.plan.as_str()), + ) + })?; let quote = quote_runtime_profile_membership_upgrade(membership_upgrade_quote_input( &row, ¤t, &target, created_at, )) - .map_err(|rejection| format!("会员升级被拒绝: {}", rejection.as_str()))?; + .map_err(|rejection| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::UpgradeRejected, + format!("会员升级被拒绝: {}", rejection.as_str()), + ) + })?; Ok(ProfileMembershipOrderSnapshot { change_kind: RuntimeProfileMembershipChangeKind::Upgrade, plan, @@ -10071,7 +10224,7 @@ fn apply_profile_membership_purchase( user_id: &str, order: &ProfileRechargeOrder, purchased_at: Timestamp, -) -> Result { +) -> Result { refresh_profile_wallet_expiring_points(ctx, user_id, purchased_at); let active_row = ctx @@ -10098,7 +10251,11 @@ fn apply_profile_membership_purchase( if settlement.change_kind == RuntimeProfileMembershipChangeKind::Purchase && active_row.is_some() { - return Err("会员状态已变化,请重新下单".to_string()); + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::StateChanged, + "会员状态已变化,请重新下单", + ) + .into()); } if settlement.change_kind == RuntimeProfileMembershipChangeKind::Upgrade { @@ -10109,12 +10266,20 @@ fn apply_profile_membership_purchase( if settlement.plan_before != Some(row.plan) || settlement.cycle_kind_before != Some(row.cycle_kind) { - return Err("会员状态已变化,请重新下单".to_string()); + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::StateChanged, + "会员状态已变化,请重新下单", + ) + .into()); } // 中文注释:冻结快照记录了下单当期的期号与刷新时刻。若支付前账期已推进, // 按旧周期剩余比例算出的补点会叠加到已重置的新周期上(多发泥点),因此拒绝。 if !frozen_membership_cycle_matches(&settlement, &row) { - return Err("会员状态已变化,请重新下单".to_string()); + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::StateChanged, + "会员状态已变化,请重新下单", + ) + .into()); } return apply_profile_membership_upgrade( ctx, @@ -10123,15 +10288,26 @@ fn apply_profile_membership_purchase( &order.order_id, &settlement, purchased_at, - ); + ) + .map_err(ProfileMembershipProcedureError::from); } // 中文注释:会员在支付前已到期,冻结的补差价只覆盖当期剩余权益的差额, // 不再覆盖整期新购价;只有订单金额达到目标周期当前目录价才按新购结算, // 否则拒绝,避免用户用零头补差价换到一个完整周期。 let target = membership_plan_snapshot(ctx, settlement.plan, settlement.cycle_kind) - .ok_or_else(|| format!("会员目录缺少档位 {}", settlement.plan.as_str()))?; + .ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!("会员目录缺少档位 {}", settlement.plan.as_str()), + ) + })?; check_expired_membership_upgrade_settlement(order.amount_cents, target.price_cents) - .map_err(|_| "会员已到期,请按新购价格重新下单".to_string())?; + .map_err(|_| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::MembershipExpired, + "会员已到期,请按新购价格重新下单", + ) + })?; } apply_profile_membership_fresh_purchase( @@ -10141,6 +10317,7 @@ fn apply_profile_membership_purchase( &settlement, purchased_at, ) + .map_err(ProfileMembershipProcedureError::from) } /// 历史 Pending 订单(建单时还没有冻结快照)的结算口径:按结算时的目录与会员状态重算。 @@ -10150,11 +10327,20 @@ fn build_recomputed_profile_membership_settlement( product_id: &str, order_amount_cents: u64, purchased_at: Timestamp, -) -> Result { - let (plan, cycle_kind) = parse_runtime_profile_membership_product_id(product_id) - .ok_or_else(|| format!("会员商品 product_id 无法解析: {product_id}"))?; - let target = membership_plan_snapshot(ctx, plan, cycle_kind) - .ok_or_else(|| format!("会员目录缺少档位 {}", plan.as_str()))?; +) -> Result { + let (plan, cycle_kind) = + parse_runtime_profile_membership_product_id(product_id).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::ProductIdUnparsable, + format!("会员商品 product_id 无法解析: {product_id}"), + ) + })?; + let target = membership_plan_snapshot(ctx, plan, cycle_kind).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!("会员目录缺少档位 {}", plan.as_str()), + ) + })?; let Some(row) = active_row else { return Ok(ProfileMembershipSettlement { change_kind: RuntimeProfileMembershipChangeKind::Purchase, @@ -10175,20 +10361,35 @@ fn build_recomputed_profile_membership_settlement( }); }; if row.plan == plan { - return Err("当前套餐已生效,无需重复购买".to_string()); + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::AlreadyActive, + "当前套餐已生效,无需重复购买", + )); } if row.cycle_kind != cycle_kind { - return Err("当前会员有效期内不支持月付 / 年付转换,到期后可重新选择".to_string()); + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::CycleKindLocked, + "当前会员有效期内不支持月付 / 年付转换,到期后可重新选择", + )); } - let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind) - .ok_or_else(|| format!("会员目录缺少档位 {}", row.plan.as_str()))?; + let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!("会员目录缺少档位 {}", row.plan.as_str()), + ) + })?; let quote = quote_runtime_profile_membership_upgrade(membership_upgrade_quote_input( row, ¤t, &target, purchased_at, )) - .map_err(|rejection| format!("会员升级被拒绝: {}", rejection.as_str()))?; + .map_err(|rejection| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::UpgradeRejected, + format!("会员升级被拒绝: {}", rejection.as_str()), + ) + })?; Ok(ProfileMembershipSettlement { change_kind: RuntimeProfileMembershipChangeKind::Upgrade, plan, From fdf636ce95d7c7b847ca90c27278bc43bf444605 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 13:19:42 +0800 Subject: [PATCH 131/232] =?UTF-8?q?=E8=A1=A5=E5=85=85(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E9=94=99=E8=AF=AF)=EF=BC=9A=E5=85=91=E6=8D=A2=E7=A0=81?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E7=95=99=20TODO=20=E6=8E=A5=E5=85=A5=20typed?= =?UTF-8?q?=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - is_runtime_profile_redeem_code_domain_error 顶部注明仍靠中文文案匹配判 400 - 指明后续按 RuntimeProfileMembershipErrorCode + ProcedureRejected 的做法改造 --- server-rs/crates/api-server/src/runtime_profile.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index acd944add..0677ef55b 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1500,6 +1500,9 @@ fn is_runtime_profile_domain_error(error: &SpacetimeClientError) -> bool { } } +// TODO(refactor): 兑换码链路尚未接入 typed error code,仍靠中文文案匹配判 400; +// 后续按会员链路(RuntimeProfileMembershipErrorCode + SpacetimeClientError::ProcedureRejected) +// 的做法,让 module 侧兑换码拒绝也携带机器可读错误码。 fn is_runtime_profile_redeem_code_domain_error(message: &str) -> bool { matches!( message, From 9d7ca1b3b73ffc3a07c01009c0d46ba5c8c55969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 13:20:06 +0800 Subject: [PATCH 132/232] =?UTF-8?q?=E6=96=87=E6=A1=A3(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E7=9B=AE=E5=BD=95)=EF=BC=9A=E6=98=8E=E7=A1=AE=E6=87=92?= =?UTF-8?q?=E6=92=AD=E7=A7=8D=E7=AD=96=E7=95=A5=E4=B8=8E=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E9=A6=96=E6=AC=A1=E5=86=99=E5=BA=93=E7=9A=84?= =?UTF-8?q?=E5=89=AF=E4=BD=9C=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ensure_default_profile_membership_plan 注释说明 init 与读取 helper 共用、目录空时首次读取会写库 - init_profile_membership_plan_catalog 注释改为不覆盖「表已存在但为空」的存放量库 - 报价与建单注释去掉「不写库」的绝对说法,注明目录空时的懒播种例外 - 同步设计文档 7 / 10.2 与决策日志(播种策略定为保留懒播种 + 明示副作用) --- .../shared-memory/decision-log.md | 7 +++++ ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 7 ++++- .../src/runtime/active/profile.rs | 27 ++++++++++++++----- 3 files changed, 34 insertions(+), 7 deletions(-) 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, From cbe6697d03e4ce9f4fdc71677f3012eb0256d482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 16:08:38 +0800 Subject: [PATCH 133/232] =?UTF-8?q?docs(=E4=BC=9A=E5=91=98):=20=E5=AE=9A?= =?UTF-8?q?=E6=A1=88=E6=A8=A1=E5=9E=8B=E6=9D=83=E9=99=90=E4=B8=8E=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E4=B8=8A=E9=99=90=E7=9A=84=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ADR「模型权限与并发上限的强制边界」:并发只在认领事务按账号过滤 running job、模型档缺省 Basic、直连同步生成暂不计入 - 技术设计新增 §11,承接并作废 §9/§10 的「只落字段、不拦截」表述 - CONTEXT 修订「模型权限」「并发上限」词条,新增「独立生成任务」 - docs/README 与 decision-log 登记本 ADR 与拷问定案 --- CONTEXT.md | 12 ++++-- docs/README.md | 1 + ...€‘模型权限与并发上限的强制边界-2026-10-03.md | 26 +++++++++++++ .../shared-memory/decision-log.md | 10 +++++ ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 39 ++++++++++++++++++- 5 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md diff --git a/CONTEXT.md b/CONTEXT.md index 1f3c268af..460a1abfc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -326,12 +326,16 @@ _Avoid_: 旧的月卡 / 季卡 / 年卡命名、把档位写成自由字符串 _Avoid_: 把价格写死在代码、按档位名字硬编码权益 **模型权限**: -会员档位允许选择的模型范围;`Starter` 只含基础模型,`Plus` / `Pro` / `Max` 含基础与高性能模型。 -_Avoid_: 计价分辨率档、模型定价档位 +会员档位允许用户选择的 Agent 模型范围,分「基础」与「高性能」两档;每个模型属于哪一档由模型目录的权限标注决定,`Starter` 只含基础模型,`Plus` / `Pro` / `Max` 含基础与高性能模型。 +_Avoid_: 计价分辨率档、模型定价档位、把档位写成自由字符串 **并发上限**: -同一账号可同时运行的独立生成任务数上限;`Max` 不设上限。 -_Avoid_: 旧的排队展示字段、把「不设上限」写成空值 +同一账号可同时处于执行中的服务端生成任务数上限;排队等待中的任务不计入,`Max` 不设上限。 +_Avoid_: 旧的排队展示字段、把「不设上限」写成空值、把排队中的任务计入 + +**独立生成任务**: +用户提交、由服务端排队并执行的单条生成工作单元(图片 / 视频 / 音频等),带独立生命周期与计点;并发上限只对处于执行中的该任务计数,与用户本地的 Agent 回合无关。 +_Avoid_: 本地 Agent 回合、前端提交节流、直连同步生成请求(当前不计入并发) ## Relationships diff --git a/docs/README.md b/docs/README.md index 14d0d17fa..20f59fa9f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -112,6 +112,7 @@ - [会员账期按开通日自然月](./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` 表示不设上限。 +- [模型权限与并发上限的强制边界](./adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md):并发上限只在认领事务按账号过滤队列 job、只算 `running`;模型权限只约束 AGC LLM 模型、缺省 `Basic` 由后台人工标 `Full`;raw gpt-image-2 等直连同步生成暂不计入并发。 ## 后端、运维与测试 diff --git a/docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md b/docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md new file mode 100644 index 000000000..c817b6756 --- /dev/null +++ b/docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md @@ -0,0 +1,26 @@ +# 模型权限与并发上限的强制边界 + +## Status + +accepted(2026-10-03) + +## 背景与决策 + +`profile_membership_plan` 的 `model_access` 与 `concurrent_job_limit` 此前只落字段、只用于展示,服务端不做拦截。2026-10-03 决定升级为服务端强制,并划清本期边界: + +- **并发上限**只在已有的 `claim_external_generation_jobs_tx`(唯一的 `pending → running` 事务点)按账号过滤,只统计 `status = running` 的 `external_generation_job`;不新建队列、不设排队深度上限、不新增计数表(加 `(owner_user_id, status)` 组合索引现算)。上限读后台目录 `profile_membership_plan`,缺行失败关闭到 `Normal`(=1),`128` 哨兵表示不设上限。 +- **模型权限**只约束 AGC 的 LLM 模型:`AgcModel` 末位加权限档,**缺省 `Basic`(失败开放)**,由人工在后台「AGC 模型」页手动标 `Full`;在 `GET /api/llm/models` 与代理解析处校验,越权返回 4xx `MODEL_NOT_AVAILABLE_FOR_PLAN`。 +- **直连同步生成暂不计入并发**:`/api/raw/v1/images/edit`(gpt-image-2)、`editor_project` 直连图片 / 图标 / 背景、角色资源与音频等路径不产生 job 行,`running` 计数覆盖不到;统一「账号在飞生成」口径留作后续设计。 +- 不设灰度开关,直接对所有账号生效。 + +## 考虑过的替代方案 + +- 新增 SpacetimeDB「在飞租约表」统一统计队列 job 与直连同步生成:更完整,但需要租约、心跳与回收语义,本期未做。 +- 把直连同步生成统一改走 `external_generation_job`:最一致,但要重做 raw / editor 直连接口,风险最大。 +- 模型档位缺省 `Full`(失败关闭):更安全,但上线即需先标注全部基础模型,运营成本高。 + +## 后果 + +- 模型限制在人工标注 `Full` 之前不会拦截任何请求。 +- `raw` gpt-image-2 与其它直连同步生成不受 `concurrent_job_limit` 约束(TODO)。 +- 无灰度开关;出问题只能回滚发版。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 3cb8cf1ef..6d0c7a9d0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9621,6 +9621,16 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证:`cargo check -p api-server`;`npm run admin-web:typecheck`;`npx vitest run apps/admin-web` → 26 files / 248 tests passed(新增「会员订单在发放泥点列展示会员变更快照」及用户详情会员断言)。 - 边界:`/admin/api/*` 不在 `/api/external/v1` OpenAPI 门禁内,本次未补接口级契约测试,会员快照只在管理端页面用例层面取证。 + +## 2026-10-03 模型权限与并发上限的强制边界(拷问定案) + +- 背景:`model_access` 与 `concurrent_job_limit` 此前只落字段、只用于展示,服务端零拦截;用户点出 raw gpt-image-2 这类同步生成,确认「只统计 job 的 `running`」覆盖不到它,因此先把「并发单位」定义清楚再强制。 +- 决策(并发上限只约束队列 job):强制点放在唯一的 `pending → running` 事务点 `claim_external_generation_jobs_tx`,认领时按账号统计 `running` 数、达上限则跳过(任务留在 `pending`,天然排队,不新建队列)。只算 `running`(含过期待回收的 `expired_running`);失败退回 `pending` 重试释放名额;自身过期 `running` 回收豁免;上限读 `profile_membership_plan.concurrent_job_limit`,缺行失败关闭到 `Normal`(=1),`128` 哨兵为不设上限;计数用新增 `(owner_user_id, status)` 组合索引现算,不建计数表;`/api/external/v1` 任务同样计入;不设排队深度上限。 +- 决策(模型权限只约束 AGC LLM 模型):`AgcModel` 末位加权限档、缺省 `Basic`(失败开放),由人工在后台「AGC 模型」页手动标 `Full`;`GET /api/llm/models` 按档过滤并把 `defaultModelId` 取可用集合首个,`/api/llm/responses` 经 `resolve_requested` 后校验、越权返 `MODEL_NOT_AVAILABLE_FOR_PLAN`;本地残留旧选择明确 4xx,不静默回退;不设灰度开关。 +- 边界(本轮不做):直连同步生成(raw gpt-image-2、`editor_project` 直连图片 / 图标 / 背景、角色资源、音频)没有 job 行,`running` 计数覆盖不到;统一「账号在飞生成」口径(在飞租约表 / 或统一改走 job)留作后续 TODO。 +- 影响范围:`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md`(新增 §11,修订 §9/§10)、`CONTEXT.md`(并发上限 / 新增「独立生成任务」)、`docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md`、`docs/README.md`。未改代码。 +- 验证:`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 + ## 2026-10-03 会员链路错误分类改为机器可读错误码(typed error) - 背景:`api-server/src/runtime_profile.rs` 的 `is_runtime_profile_membership_domain_error` 用中文 `error_message` 前缀 / 精确匹配决定返回 400 还是 502;文案一改(或新增拒绝点)就静默退化,且分类逻辑与 module 的错误文案跨层重复。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index a97893ab5..9f0718dad 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -511,6 +511,8 @@ flowchart TD | 14 | 下单接口 | 复用 `POST /api/profile/recharge/orders` | | 15 | `walletBalance` | 用户面不暴露 | +> **2026-10-03 更新**:上表第 6、7 行的「本期不实现限制」由 §11 取代——`model_access` 与 `concurrent_job_limit` 进入服务端强制。 + ## 10. 评审输入与实施清单 ### 10.1 已确认输入(本轮拍板) @@ -558,10 +560,45 @@ flowchart TD 未做(依决策保留,不是遗漏): -- 模型权限与并发上限**只落字段**,服务端不做拦截。 +- 模型权限与并发上限**只落字段**,服务端不做拦截。(2026-10-03 改:已由 §11 升级为服务端强制,本条作废。) - 不提供 `availableTotalPoints`;合计由前端派生。 - 会员购买 / 升级的 C 端 UI 未做(会员侧未上线,充值弹层只陈列泥点商品)。 - 永久泥点仍是「总额 − 每日免费 − 月度」的推导余数,独立存储见 [`【ADR】泥点三池以单一总额为权威`](../../adr/【ADR】泥点三池以单一总额为权威-2026-10-02.md) 的 TODO 与触发条件。 +## 11. 模型权限与并发上限的强制(2026-10-03 定案) + +> 承接 §9 第 6/7 行与 §10「未做」:把 `model_access` 与 `concurrent_job_limit` 从「只落字段、只用于展示」升级为**服务端强制**。 +> 范围:`module-runtime`(目录与判定纯函数)、`spacetime-module`(认领事务 + schema 索引)、`api-server`(模型代理)、`shared-contracts`、后台「AGC 模型」页。**不含 C 端 UI、不含灰度开关。** + +### 11.1 并发上限(只约束队列 job) + +- **强制点唯一**:`claim_external_generation_jobs_tx`(`spacetime-module/src/external_generation.rs`)是唯一的 `pending → running` 事务点。认领时按账号统计在飞数,未达上限才置 `running`;达上限则**跳过**该账号的候选任务——任务保持 `pending`,天然形成排队,无需新建队列。 +- **计入口径**:只算 `status = running`(含 lease 过期待回收的 `expired_running`)。失败退回 `pending` 等待重试的任务**释放**名额;`pending`(含 `available_at` 延时重试)不计入。 +- **回收豁免**:同一账号 lease 已过期的 `running` 被回收重认领时**不受自身上限限制**,否则超限账号的卡死任务永远无法回收。 +- **上限来源**:`profile_membership.plan` → `profile_membership_plan.concurrent_job_limit`(后台权威);缺会员行 / 缺目录行**失败关闭**到 `Normal`(=1);`128` 哨兵 = 不设上限(复用 `is_unlimited_concurrency`)。 +- **计数实现**:给 `external_generation_job` 追加 `(owner_user_id, status)` 组合索引,事务内现算 `running` 行数;**不**新增计数表(`running` 行即真相)。 +- **覆盖范围**:本期只约束 `external_generation_job`;`/api/external/v1` 触发、挂在账号下的任务**同样计入**。 +- **排队深度**:**不设**单账号 `pending` 深度上限。 +- **⚠️ TODO(用户保留,后续单独设计)**:**直连同步生成不计入本期并发**——`/api/raw/v1/images/edit`(gpt-image-2,见 [`【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07`](./【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md))、`editor_project` 直连图片 / 图标 / 背景、`character_visual_assets` / `character_animation_assets`、`vector_engine_audio_generation` 等路径**没有** `external_generation_job` 行,`running` 计数覆盖不到。统一「账号在飞生成」口径(在飞租约表 / 或统一改走 job)留作后续设计。当前全局护栏仍只有 worker 并发、`max_concurrent_requests` 与 raw 解码信号量。 + +### 11.2 模型权限(只约束 AGC LLM 模型) + +- **字段**:`AgcModel`(`module-runtime/src/agc_models.rs`)末位追加权限档字段(`Basic` / `Full`),**缺省 `Basic`(失败开放)**;存量目录 JSON 缺该字段时按 `Basic` 解析。目录仍是一行 JSON + `revision` 乐观锁,非表。 +- **标注**:由**人工在后台「AGC 模型」页手动**把高性能模型标为 `Full`;代码里**不**写死清单、不播种。 +- **档位解析**:`load_llm_catalog(owner)` 已带 owner;由 `profile_membership.plan` → 目录 `model_access` 解析;缺档位失败关闭到 `Normal`(`Basic`)。 +- **生效点**: + - `GET /api/llm/models`:按账号档位过滤(`Basic` 档只看到非 `Full` 模型),`defaultModelId` 取该档可用集合的默认项(见 §11.4)。 + - `POST /api/llm/responses`:`catalog.resolve_requested()` 映射后校验档位,越权返回 4xx 专用错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`。 + - `POST /api/llm/chat/completions`(`model` 恒为默认)与 anthropic bridge:只需保证解析出的默认模型落在该档可用集合内。 + - 客户端本地残留旧选择(`select_game_creator_model` 持久化)→ **明确 4xx**,由客户端提示重选;**不静默回退**。 +- **覆盖范围**:只约束 AGC 对话 / Agent 的 LLM 模型;图片 / 视频 / 音频等生成模型不纳入;`/api/external/v1` 不纳入。 + +### 11.3 共同约定 + +- **不设灰度开关**:直接对所有账号生效(2026-10-03 定案)。 +- 因为模型档位缺省 `Basic`,**上线后若不人工标 `Full`,模型限制不会产生任何拦截**——这是有意的失败开放,操作责任在后台。 + +--- + > 约束来源:`scripts/check-spacetime-schema-guard.mjs` 只做源码静态比对,破坏性变更必须显式带 `SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1` 才会放行;而 `module-runtime` 与 `spacetime-module` 相互依赖,任一侧单独清退都会让另一侧无法编译,所以领域层清退必须与 schema 变更同批提交。 From 56708988edf323e22ca7d7f5979f0fa24ae58847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 16:13:14 +0800 Subject: [PATCH 134/232] =?UTF-8?q?docs(=E4=BC=9A=E5=91=98):=20=E8=A1=A5?= =?UTF-8?q?=20=C2=A711.4=20=E5=AE=9E=E7=8E=B0=E8=90=BD=E7=82=B9=EF=BC=8C?= =?UTF-8?q?=E9=92=89=E6=AD=BB=E6=A8=A1=E5=9E=8B=E6=9D=83=E9=99=90=E4=B8=8E?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E7=9A=84=E7=BC=96=E7=A0=81=E7=BA=A6=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 §11.4:module-runtime 的 AgcModelAccess / 解析错误、spacetime-module 的 (owner_user_id, status) 索引与认领过滤、api-server 的档位解析与 403 错误码、后台 access 字段 - 明确默认模型语义:登记默认项优先,不在该档可用集合里时回退目录顺序首个可用项 - 记录本期复用 get_profile_recharge_center 解析档位的性能 TODO --- ...�技术设计】泥点三池与会员计费后端设计-2026-10-02.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index 9f0718dad..46cc93b20 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -599,6 +599,17 @@ flowchart TD - **不设灰度开关**:直接对所有账号生效(2026-10-03 定案)。 - 因为模型档位缺省 `Basic`,**上线后若不人工标 `Full`,模型限制不会产生任何拦截**——这是有意的失败开放,操作责任在后台。 +### 11.4 实现落点(2026-10-03 编码约定) + +- **`module-runtime`**: + - 新增 `agc_model_access.rs`:权限档 `AgcModelAccess`(`basic` / `full`,`Default = Basic`)、越权错误码常量 `MODEL_NOT_AVAILABLE_FOR_PLAN`、解析错误 `AgcModelResolveError::{Unavailable, NotAvailableForPlan}`,以及 `RuntimeProfileMembershipModelAccess → AgcModelAccess` 的映射。 + - `AgcModel` 末位追加 `#[serde(default)] access: AgcModelAccess`(存量目录 JSON 缺字段即 `Basic`,失败开放);`AgcModelCatalog` 新增 `available_models_for(access)`、`default_model_id_for(access)`、`resolve_requested_for(requested, access)`。 + - **默认模型语义**:优先取目录登记的 `default_model_id`;当它不在该档可用集合里时,回退到目录顺序里第一个可用项;该档一个可用模型都没有时返回 `Unavailable`。 +- **`spacetime-module`**:`external_generation_job` 追加 btree 索引 `(owner_user_id, status)`(`by_external_generation_job_owner_status`);`claim_external_generation_jobs_tx` 按账号解析 `concurrent_job_limit` 并在认领时过滤;上限解析走 `profile_membership.plan` → `profile_membership_plan.concurrent_job_limit`,缺会员行 / 缺目录行失败关闭到 `Normal`(=1)。 +- **`api-server`**:新增 `llm/model_access.rs`,用现有 `get_profile_recharge_center` 读出账号 `membership.plan` 与目录行 `model_access` 并映射到 `AgcModelAccess`;`GET /api/llm/models` 按档过滤,`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全部按档解析,越权返回 403 + 错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`。 +- **后台**:`AdminAgcModel` 与后台「AGC 模型」页新增 `access` 字段(`basic` / `full`,缺省 `basic`),GET 回读、PUT 写入;**不做**自动播种或代码内清单。 +- **性能说明**:本期账号档位复用现有 `get_profile_recharge_center` 读模型,它带幂等的周期刷新写入;后续可替换为更轻的专用读(TODO)。 + --- > 约束来源:`scripts/check-spacetime-schema-guard.mjs` 只做源码静态比对,破坏性变更必须显式带 `SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1` 才会放行;而 `module-runtime` 与 `spacetime-module` 相互依赖,任一侧单独清退都会让另一侧无法编译,所以领域层清退必须与 schema 变更同批提交。 From 325466d9b42de4256392b37b704ccf3ec8be9a3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 16:13:30 +0800 Subject: [PATCH 135/232] =?UTF-8?q?feat(=E4=BC=9A=E5=91=98):=20AGC=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=9B=AE=E5=BD=95=E6=96=B0=E5=A2=9E=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=A1=A3=E4=B8=8E=E6=8C=89=E6=A1=A3=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 agc_model_access.rs:AgcModelAccess(basic/full,缺省 basic 失败开放)、越权错误码 MODEL_NOT_AVAILABLE_FOR_PLAN、AgcModelResolveError,以及会员档位到模型权限档的映射 - AgcModel 末位追加 #[serde(default)] access,存量目录 JSON 缺字段按 basic 解析 - AgcModelCatalog 新增 available_models_for / default_model_id_for / resolve_requested_for:显式越权不静默回退,默认模型优先登记项、不可用时回退目录顺序首个可用项 - 覆盖缺省解析、档位过滤、越权与未知模型区分、无可用模型等纯函数测试 --- .../module-runtime/src/agc_model_access.rs | 290 ++++++++++++++++++ .../crates/module-runtime/src/agc_models.rs | 7 + server-rs/crates/module-runtime/src/lib.rs | 2 + 3 files changed, 299 insertions(+) create mode 100644 server-rs/crates/module-runtime/src/agc_model_access.rs diff --git a/server-rs/crates/module-runtime/src/agc_model_access.rs b/server-rs/crates/module-runtime/src/agc_model_access.rs new file mode 100644 index 000000000..76559aaed --- /dev/null +++ b/server-rs/crates/module-runtime/src/agc_model_access.rs @@ -0,0 +1,290 @@ +//! AGC 模型权限档与「按会员档位过滤模型目录」的判定(纯函数,无 IO)。 +//! +//! 模型目录是全局的,但每个模型带一个权限档;会员档位(`profile_membership_plan.model_access`) +//! 决定账号能看到、能调用哪些模型。这里只做判定与解析,SpacetimeDB 读写留在 `spacetime-module`, +//! HTTP 错误映射留在 `api-server`。 +//! +//! 失败开放:模型权限档缺省 [`AgcModelAccess::Basic`],因此存量目录 JSON 缺字段、后台未人工标注 +//! `Full` 时都不会拦截任何请求;只有明确标了 `Full` 的模型才会对 `Basic` 档账号报越权。 +//! +//! 本文件把「档位可见集合」「档位默认模型」「请求侧解析」收在一处,调用方只接触 +//! [`AgcModelCatalog::resolve_requested_for`] 这类语义方法,不自己拼过滤条件。 + +use serde::{Deserialize, Serialize}; + +use crate::{ + AGC_MODEL_PLATFORM_DEFAULT, AgcModel, AgcModelCatalog, RuntimeProfileMembershipModelAccess, +}; + +/// 越权选择模型时对外返回的专用错误码(`api-server` 直接透传到 `AppError.code`)。 +pub const MODEL_NOT_AVAILABLE_FOR_PLAN: &str = "MODEL_NOT_AVAILABLE_FOR_PLAN"; + +/// 单个模型要求的权限档。 +/// +/// 缺省 `Basic`(失败开放):存量目录 JSON 缺该字段、后台未人工标注都按基础档处理, +/// 只有被后台明确标成 `Full` 的模型才会对 `Basic` 档账号产生拦截。 +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AgcModelAccess { + #[default] + Basic, + Full, +} + +impl AgcModelAccess { + pub const fn as_str(self) -> &'static str { + match self { + Self::Basic => "basic", + Self::Full => "full", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "basic" => Some(Self::Basic), + "full" => Some(Self::Full), + _ => None, + } + } + + /// 当前账号档位是否覆盖模型要求的档位:`Full` 覆盖全部,`Basic` 只覆盖基础。 + pub const fn allows(self, required: Self) -> bool { + matches!( + (self, required), + (Self::Full, _) | (Self::Basic, Self::Basic) + ) + } +} + +impl From for AgcModelAccess { + fn from(value: RuntimeProfileMembershipModelAccess) -> Self { + match value { + RuntimeProfileMembershipModelAccess::Basic => Self::Basic, + RuntimeProfileMembershipModelAccess::Full => Self::Full, + } + } +} + +/// 模型解析失败原因;调用方据此选择错误码与 HTTP 状态,不靠字符串匹配。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AgcModelResolveError { + /// 标识不在目录、模型已停用,或该档位没有任何可用模型。 + Unavailable, + /// 模型存在且启用,但当前会员档位不够(越权)。 + NotAvailableForPlan, +} + +impl AgcModelResolveError { + pub const fn code(self) -> &'static str { + match self { + Self::Unavailable => "MODEL_UNAVAILABLE", + Self::NotAvailableForPlan => MODEL_NOT_AVAILABLE_FOR_PLAN, + } + } + + pub const fn message(self) -> &'static str { + match self { + Self::Unavailable => "所选模型不可用,请刷新模型列表", + Self::NotAvailableForPlan => "当前会员档位不能使用该模型,请升级会员或重新选择模型", + } + } +} + +impl AgcModel { + /// 该模型对给定账号档位是否可见 / 可调用(既要启用,也要档位足够)。 + pub fn is_available_for(&self, access: AgcModelAccess) -> bool { + self.enabled && access.allows(self.access) + } +} + +impl AgcModelCatalog { + /// 该档位可见的模型,保持目录顺序。 + pub fn available_models_for(&self, access: AgcModelAccess) -> impl Iterator { + self.models + .iter() + .filter(move |model| model.is_available_for(access)) + } + + /// 该档位的默认模型标识:优先目录登记的 `default_model_id`,不在该档可用集合里时 + /// 回退到目录顺序里第一个可用项;一个可用模型都没有时返回 `None`。 + pub fn default_model_id_for(&self, access: AgcModelAccess) -> Option<&str> { + let configured = self + .models + .iter() + .find(|model| model.id == self.default_model_id) + .filter(|model| model.is_available_for(access)); + if configured.is_some() { + return Some(self.default_model_id.as_str()); + } + self.available_models_for(access) + .map(|model| model.id.as_str()) + .next() + } + + /// 请求侧解析(带档位)。 + /// + /// `requested` 为空或占位标识时用该档默认模型;显式标识必须是目录内、启用、且档位允许的模型。 + /// 目录外 / 已停用 → [`AgcModelResolveError::Unavailable`];档位不够 → [`AgcModelResolveError::NotAvailableForPlan`], + /// **不静默回退**成其它模型。 + pub fn resolve_requested_for( + &self, + requested: Option<&str>, + access: AgcModelAccess, + ) -> Result<&str, AgcModelResolveError> { + let requested = requested + .map(str::trim) + .filter(|id| !id.is_empty() && *id != AGC_MODEL_PLATFORM_DEFAULT); + match requested { + Some(id) => match self.models.iter().find(|model| model.id == id) { + Some(model) if !model.enabled => Err(AgcModelResolveError::Unavailable), + Some(model) if access.allows(model.access) => Ok(model.model_id.as_str()), + Some(_) => Err(AgcModelResolveError::NotAvailableForPlan), + None => Err(AgcModelResolveError::Unavailable), + }, + None => { + let id = self + .default_model_id_for(access) + .ok_or(AgcModelResolveError::Unavailable)?; + self.models + .iter() + .find(|model| model.id == id && model.enabled) + .map(|model| model.model_id.as_str()) + .ok_or(AgcModelResolveError::Unavailable) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn catalog_from_ids(ids: &[&str], default_index: usize) -> AgcModelCatalog { + let mut catalog = + AgcModelCatalog::from_upstream_models(ids.iter().map(|id| id.to_string()), 0) + .expect("目录应从上游模型名构建"); + let default_id = catalog.models[default_index].id.clone(); + catalog.default_model_id = default_id; + catalog + } + + fn mark_full(catalog: &mut AgcModelCatalog, id: &str) { + let model = catalog + .models + .iter_mut() + .find(|model| model.id == id) + .expect("测试模型应存在"); + model.access = AgcModelAccess::Full; + } + + #[test] + fn access_defaults_to_basic_and_parses_strictly() { + assert_eq!(AgcModelAccess::default(), AgcModelAccess::Basic); + assert_eq!(AgcModelAccess::parse("basic"), Some(AgcModelAccess::Basic)); + assert_eq!(AgcModelAccess::parse("full"), Some(AgcModelAccess::Full)); + assert_eq!(AgcModelAccess::parse("Full"), None); + assert_eq!(AgcModelAccess::parse(""), None); + } + + #[test] + fn full_plan_covers_every_model_and_basic_plan_only_basic() { + assert!(AgcModelAccess::Full.allows(AgcModelAccess::Basic)); + assert!(AgcModelAccess::Full.allows(AgcModelAccess::Full)); + assert!(AgcModelAccess::Basic.allows(AgcModelAccess::Basic)); + assert!(!AgcModelAccess::Basic.allows(AgcModelAccess::Full)); + } + + #[test] + fn legacy_catalog_json_defaults_missing_access_to_basic() { + // 存量目录 JSON 没有 access 字段:按 Basic 解析,失败开放。 + let catalog: AgcModelCatalog = serde_json::from_str( + r#"{"revision":1,"defaultModelId":"model-a","models":[{"id":"model-a","alias":"model-a","modelId":"model-a","enabled":true,"agentMode":"codex"}]}"#, + ) + .expect("存量目录应可读"); + assert_eq!(catalog.models[0].access, AgcModelAccess::Basic); + } + + #[test] + fn basic_plan_does_not_see_full_models_and_gets_first_basic_default() { + let mut catalog = catalog_from_ids(&["model-a", "model-b", "model-c"], 1); + mark_full(&mut catalog, "model-b"); + + let visible = catalog + .available_models_for(AgcModelAccess::Basic) + .map(|model| model.id.as_str()) + .collect::>(); + assert_eq!(visible, vec!["model-a", "model-c"]); + + // 登记的默认项 model-b 是 Full,Basic 档回退到目录顺序里第一个可用项。 + assert_eq!( + catalog.default_model_id_for(AgcModelAccess::Basic), + Some("model-a") + ); + // Full 档保留登记的默认项。 + assert_eq!( + catalog.default_model_id_for(AgcModelAccess::Full), + Some("model-b") + ); + } + + #[test] + fn resolve_requested_for_distinguishes_unknown_from_out_of_plan() { + let mut catalog = catalog_from_ids(&["model-a", "model-b"], 0); + mark_full(&mut catalog, "model-b"); + + assert_eq!( + catalog.resolve_requested_for(Some("model-b"), AgcModelAccess::Basic), + Err(AgcModelResolveError::NotAvailableForPlan) + ); + assert_eq!( + catalog.resolve_requested_for(Some("model-missing"), AgcModelAccess::Basic), + Err(AgcModelResolveError::Unavailable) + ); + assert_eq!( + catalog.resolve_requested_for(Some("model-b"), AgcModelAccess::Full), + Ok("model-b") + ); + // 未选择时用该档默认模型。 + assert_eq!( + catalog.resolve_requested_for(None, AgcModelAccess::Basic), + Ok("model-a") + ); + assert_eq!( + catalog.resolve_requested_for(Some(AGC_MODEL_PLATFORM_DEFAULT), AgcModelAccess::Full), + Ok("model-a") + ); + } + + #[test] + fn disabled_model_is_unavailable_even_for_full_plan() { + let mut catalog = catalog_from_ids(&["model-a", "model-b"], 0); + catalog.models[1].enabled = false; + assert_eq!( + catalog.resolve_requested_for(Some("model-b"), AgcModelAccess::Full), + Err(AgcModelResolveError::Unavailable) + ); + } + + #[test] + fn plan_with_no_available_model_falls_back_to_unavailable() { + let mut catalog = catalog_from_ids(&["model-a"], 0); + mark_full(&mut catalog, "model-a"); + assert_eq!(catalog.default_model_id_for(AgcModelAccess::Basic), None); + assert_eq!( + catalog.resolve_requested_for(None, AgcModelAccess::Basic), + Err(AgcModelResolveError::Unavailable) + ); + } + + #[test] + fn resolve_error_codes_are_stable() { + assert_eq!( + AgcModelResolveError::NotAvailableForPlan.code(), + MODEL_NOT_AVAILABLE_FOR_PLAN + ); + assert_eq!( + AgcModelResolveError::Unavailable.code(), + "MODEL_UNAVAILABLE" + ); + } +} diff --git a/server-rs/crates/module-runtime/src/agc_models.rs b/server-rs/crates/module-runtime/src/agc_models.rs index a6d406afc..05e115db8 100644 --- a/server-rs/crates/module-runtime/src/agc_models.rs +++ b/server-rs/crates/module-runtime/src/agc_models.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use std::collections::HashSet; +use crate::AgcModelAccess; + /// 目录 revision 乐观锁冲突。 pub const AGC_MODEL_CATALOG_CONFLICT: &str = "AGC_MODEL_CATALOG_CONFLICT"; /// 目录尚未初始化:SpacetimeDB 缺行,或存量内容与当前定义不符。 @@ -120,6 +122,9 @@ pub struct AgcModel { /// 保存时必须与 `agent_mode` 组成合法组合。 #[serde(default)] pub protocol: Option, + /// 该模型要求的会员档位;缺省 `Basic`(失败开放),由人工在后台标 `Full`。 + #[serde(default)] + pub access: AgcModelAccess, } impl AgcModel { @@ -169,6 +174,7 @@ impl AgcModelCatalog { enabled: true, agent_mode: AgcAgentMode::default(), protocol: None, + access: AgcModelAccess::default(), }); } let default_model_id = entries @@ -316,6 +322,7 @@ mod tests { enabled: true, agent_mode: AgcAgentMode::default(), protocol: None, + access: AgcModelAccess::default(), } } diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index fb0cb083d..116e51d10 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -2,6 +2,7 @@ pub mod agc_analytics; pub mod civil_calendar; pub mod membership; +mod agc_model_access; mod agc_models; mod payment; pub use payment::*; @@ -12,6 +13,7 @@ mod errors; mod events; mod llm_billing; +pub use agc_model_access::*; pub use agc_models::*; pub use application::*; pub use commands::*; From 103280025c1915ae084fcb27a5a8ed38bc8aaa85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 16:20:34 +0800 Subject: [PATCH 136/232] =?UTF-8?q?feat(=E4=BC=9A=E5=91=98):=20=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E7=94=9F=E6=88=90=E8=AE=A4=E9=A2=86=E6=8C=89=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E5=B9=B6=E5=8F=91=E4=B8=8A=E9=99=90=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - external_generation_job 追加 (owner_user_id, status) 组合索引,认领事务内现算 running 行数,不建计数表 - claim_external_generation_jobs_tx 认领前判定会员并发上限:达上限的 pending 保持排队 - 账号自身过期 running 的回收豁免上限,避免超限账号卡死任务无法回收 - profile.rs 新增 effective_profile_concurrent_job_limit:缺会员行按 Normal、缺目录行失败关闭到 1 - 抽取纯函数 external_generation_claim_within_concurrency_limit 并补单元测试 --- .../src/external_generation.rs | 86 +++++++++++++++++++ .../src/runtime/active/profile.rs | 20 +++++ 2 files changed, 106 insertions(+) diff --git a/server-rs/crates/spacetime-module/src/external_generation.rs b/server-rs/crates/spacetime-module/src/external_generation.rs index 5e93b162c..ab09c27c3 100644 --- a/server-rs/crates/spacetime-module/src/external_generation.rs +++ b/server-rs/crates/spacetime-module/src/external_generation.rs @@ -47,6 +47,11 @@ const INLINE_MEDIA_WARNING_REDACTED_MESSAGE: &str = accessor = by_external_generation_job_owner_user_id, btree(columns = [owner_user_id]) ), + // 会员并发上限在认领事务里按账号现算 `status = running` 行数;不建计数表,`running` 行即真相。 + index( + accessor = by_external_generation_job_owner_status, + btree(columns = [owner_user_id, status]) + ), index( accessor = by_external_generation_job_cursor, btree(columns = [job_id, source_module]) @@ -885,6 +890,11 @@ fn claim_external_generation_jobs_tx( finalize_external_generation_job_after_lease_exhaustion(ctx, row, claim_time)?; continue; } + // 会员并发上限:只约束 `pending → running` 的认领。达上限的任务留在 `pending` 天然排队; + // 账号自己那条已过期的 `running` 回收不受限,否则超限账号的卡死任务永远无法回收。 + if !external_generation_owner_has_concurrency_capacity(ctx, &row, claim_time) { + continue; + } let next_attempt = row.attempt.saturating_add(1); let lease_token = build_external_generation_lease_token( &row.job_id, @@ -917,6 +927,53 @@ fn claim_external_generation_jobs_tx( Ok(claimed) } +/// 认领前的并发上限判定。 +/// +/// 计数只算 `status = running`(含 lease 过期待回收的 `expired_running`):`pending`(含延时重试) +/// 不占名额,失败退回 `pending` 会自然释放。`128` 哨兵由 [`is_unlimited_concurrency`] 解释。 +fn external_generation_owner_has_concurrency_capacity( + ctx: &ReducerContext, + row: &ExternalGenerationJob, + now: Timestamp, +) -> bool { + let limit = crate::effective_profile_concurrent_job_limit(ctx, &row.owner_user_id); + if is_unlimited_concurrency(limit) { + return true; + } + // 回收豁免:候选本身已是该账号过期的 `running`,它已计入在飞数;放行回收不会推高在飞任务数。 + let is_recycling = row.status == EXTERNAL_GENERATION_STATUS_RUNNING + && is_external_generation_job_claimable(row, now); + external_generation_claim_within_concurrency_limit( + is_recycling, + count_running_external_generation_jobs_for_owner(ctx, &row.owner_user_id), + limit, + ) +} + +/// 并发上限判定(纯函数):`running_count >= limit` 时只放行对账号自身过期 `running` 的回收。 +fn external_generation_claim_within_concurrency_limit( + is_recycling: bool, + running_count: u32, + limit: u32, +) -> bool { + is_unlimited_concurrency(limit) || is_recycling || running_count < limit +} + +/// 账号当前在飞的 `running` 行数;事务内的认领写入会立即反映到下一次计数。 +fn count_running_external_generation_jobs_for_owner( + ctx: &ReducerContext, + owner_user_id: &str, +) -> u32 { + ctx.db + .external_generation_job() + .by_external_generation_job_owner_status() + .filter(&owner_user_id.to_string()) + .filter(|row| row.status == EXTERNAL_GENERATION_STATUS_RUNNING) + .count() + .try_into() + .unwrap_or(u32::MAX) +} + fn finalize_external_generation_job_after_lease_exhaustion( ctx: &ReducerContext, row: ExternalGenerationJob, @@ -3850,6 +3907,35 @@ mod tests { } } + #[test] + fn concurrency_limit_blocks_pending_at_limit_but_recycles_expired_running() { + // pending 在飞数达到上限后不再认领,任务保持 pending 排队。 + assert!(external_generation_claim_within_concurrency_limit( + false, 0, 1 + )); + assert!(!external_generation_claim_within_concurrency_limit( + false, 1, 1 + )); + assert!(external_generation_claim_within_concurrency_limit( + false, 2, 3 + )); + assert!(!external_generation_claim_within_concurrency_limit( + false, 3, 3 + )); + + // 回收账号自身过期的 running 不受上限限制,否则超限账号的卡死任务永远无法回收。 + assert!(external_generation_claim_within_concurrency_limit( + true, 3, 1 + )); + + // 128 哨兵表示不设上限。 + assert!(external_generation_claim_within_concurrency_limit( + false, + 10_000, + MEMBERSHIP_UNLIMITED_CONCURRENCY, + )); + } + fn micros(value: i64) -> Timestamp { Timestamp::from_micros_since_unix_epoch(value) } 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 aabd8c845..7e1a9e4db 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -9480,6 +9480,26 @@ fn membership_plan_row( ctx.db.profile_membership_plan().plan().find(&plan) } +/// 账号当前有效的并发上限:`profile_membership.plan` → 目录行 `concurrent_job_limit`。 +/// +/// 缺会员行按 `Normal`(非会员);缺目录行**失败关闭**到 `Normal`(=1), +/// `128` 哨兵由 [`is_unlimited_concurrency`] 在调用方解释。 +pub(crate) fn effective_profile_concurrent_job_limit( + ctx: &ReducerContext, + owner_user_id: &str, +) -> u32 { + let plan = ctx + .db + .profile_membership() + .user_id() + .find(&owner_user_id.to_string()) + .map(|row| row.plan) + .unwrap_or(RuntimeProfileMembershipPlan::Normal); + membership_plan_row(ctx, plan) + .map(|row| row.concurrent_job_limit) + .unwrap_or(1) +} + fn membership_plan_period_points(ctx: &ReducerContext, plan: RuntimeProfileMembershipPlan) -> u64 { membership_plan_row(ctx, plan) .map(|row| row.period_points) From 2630b0f6906cbdf7740d45ae149609a4adb3aba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 16:29:03 +0800 Subject: [PATCH 137/232] =?UTF-8?q?feat(=E4=BC=9A=E5=91=98):=20=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=20AGC=20=E6=A8=A1=E5=9E=8B=E6=94=AF=E6=8C=81=E6=A0=87?= =?UTF-8?q?=E6=B3=A8=E6=9D=83=E9=99=90=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminAgcModel 新增 access 字段(缺省 basic,失败开放),GET 回读 PUT 写入 - api-server 保存时校验权限档只允许 basic/full - 后台 AGC 模型页新增「权限档」列,可按模型选择基础/高性能模型 - fake API 与页面用例覆盖权限档选择与保存 --- apps/admin-web/src/api/adminApiTypes.ts | 3 ++ .../src/pages/AdminAgcModelsPage.test.tsx | 36 +++++++++++++++++++ .../src/pages/AdminAgcModelsPage.tsx | 17 +++++++++ scripts/admin-web-fake-api.mjs | 3 ++ server-rs/crates/api-server/src/agc_models.rs | 10 ++++-- .../crates/shared-contracts/src/admin.rs | 8 +++++ 6 files changed, 75 insertions(+), 2 deletions(-) diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 715d40d64..ceb6db413 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1174,6 +1174,7 @@ export interface AdminWalletRestrictionResponse { wallet: AdminProfileWalletPayload; } export type AdminAgcAgentMode = 'codex' | 'cc'; +export type AdminAgcModelAccess = 'basic' | 'full'; export type AdminAgcModelProtocol = | 'openai_responses' | 'openai_chat' @@ -1187,6 +1188,8 @@ export interface AdminAgcModel { agentMode: AdminAgcAgentMode; /** 上游线协议,与执行器正交。 */ protocol?: AdminAgcModelProtocol; + /** 该模型要求的会员档位,缺省 `basic`(失败开放)。 */ + access?: AdminAgcModelAccess; } export interface AdminAgcModelCatalog { diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx index feb4ce9d3..3279a5342 100644 --- a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx @@ -101,10 +101,46 @@ test('new models default to Codex and save the selected Agent mode', async () => modelId: '', enabled: true, agentMode: 'cc', + access: 'basic', }); }); }); +test('权限档可按模型单独选择并随目录保存', async () => { + const catalog = { + revision: 3, + defaultModelId: 'quality', + models: [ + { + id: 'quality', + alias: '高质量', + modelId: 'gpt-6-astra', + enabled: true, + agentMode: 'codex' as const, + access: 'basic' as const, + }, + ], + }; + vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog); + vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({ + ...input, + revision: 4, + })); + render(); + await screen.findByDisplayValue('gpt-6-astra'); + + const access = screen.getByLabelText('模型 1 权限档') as HTMLSelectElement; + expect(access.value).toBe('basic'); + fireEvent.change(access, { target: { value: 'full' } }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + + await waitFor(() => { + const calls = vi.mocked(saveAgcModelCatalog).mock.calls; + const payload = calls[calls.length - 1]?.[1]; + expect(payload?.models[0]).toMatchObject({ access: 'full' }); + }); +}); + test('协议与 Agent 模式是独立字段,可按模型单独选择', async () => { const catalog = { revision: 3, diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx index b009aa222..9e14400cb 100644 --- a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx @@ -118,6 +118,7 @@ export function AdminAgcModelsPage({ { key: 'modelId', label: '实际模型名' }, { key: 'agentMode', label: 'Agent 模式' }, { key: 'protocol', label: '协议' }, + { key: 'access', label: '权限档' }, { key: 'enabled', label: '启用' }, { key: 'default', label: '默认' }, { key: 'actions', label: '操作' }, @@ -159,6 +160,7 @@ export function AdminAgcModelsPage({ enabled: true, agentMode: 'codex', protocol: 'openai_responses', + access: 'basic', }, ], }, @@ -246,6 +248,21 @@ export function AdminAgcModelsPage({ + + + , AppError>>()?; @@ -297,6 +302,7 @@ fn catalog_dto(catalog: AgcModelCatalog) -> AdminAgcModelCatalog { enabled: m.enabled, agent_mode: m.agent_mode.as_str().to_string(), protocol: Some(protocol), + access: m.access.as_str().to_string(), } }) .collect(), diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 6516d4e20..a0ff50bbd 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -1704,6 +1704,11 @@ fn default_codex_agent_mode() -> String { "codex".to_string() } +/// 存量后台请求缺省为 `basic`(失败开放),只有人工标注才收紧到 `full`。 +fn default_basic_agc_model_access() -> String { + "basic".to_string() +} + #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AdminAgcModel { @@ -1718,6 +1723,9 @@ pub struct AdminAgcModel { /// 服务端回读时始终给出解析后的具体值。 #[serde(default, skip_serializing_if = "Option::is_none")] pub protocol: Option, + /// 该模型要求的会员档位:`basic`(缺省,对全部档位开放)/ `full`(后台人工标注)。 + #[serde(default = "default_basic_agc_model_access")] + pub access: String, } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] From 3306d932bb38ad2ce2add856d521fd73c313b059 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 16:36:05 +0800 Subject: [PATCH 138/232] =?UTF-8?q?feat(=E4=BC=9A=E5=91=98):=20AGC=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E4=BB=A3=E7=90=86=E6=8C=89=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E6=A1=A3=E4=BD=8D=E8=BF=87=E6=BB=A4=E4=B8=8E=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 llm/model_access.rs:用 get_profile_recharge_center 解析账号档位到 AgcModelAccess,缺会员行/缺目录行/读失败统一失败关闭到 Basic - GET /api/llm/models 按档过滤并给出该档默认模型;该档无可用模型时返回 503 而不是不可解析的默认项 - /api/llm/responses、/api/llm/chat/completions、/api/llm/anthropic/* 全部改按档解析;越权返回 403 + MODEL_NOT_AVAILABLE_FOR_PLAN,目录外/停用仍是 422 - 补 api-server 单测:Basic 档过滤 Full 模型并回退默认项、越权错误码与状态映射 --- server-rs/crates/api-server/src/llm/mod.rs | 162 +++++++++++++----- .../crates/api-server/src/llm/model_access.rs | 55 ++++++ 2 files changed, 176 insertions(+), 41 deletions(-) create mode 100644 server-rs/crates/api-server/src/llm/model_access.rs diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 351ffbb52..385dd21cf 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -9,6 +9,7 @@ use axum::{ }, }; use futures_util::StreamExt; +use module_runtime::AgcModelResolveError; use platform_llm::{LlmApiKind, LlmMessage, LlmMessageRole, LlmRunRequest}; use serde_json::{Value, json}; use shared_contracts::llm::{ @@ -42,6 +43,10 @@ mod anthropic_bridge; pub(crate) mod icon_specs; +mod model_access; + +use model_access::{OwnerAgcModelCatalog, resolve_owner_agc_model_access}; + #[cfg(test)] mod model_catalog_tests { use super::*; @@ -54,7 +59,14 @@ mod model_catalog_tests { ) .expect("catalog should build"); catalog.models[1].enabled = false; - let payload = serde_json::to_value(public_model_catalog(catalog)).unwrap(); + let payload = serde_json::to_value( + public_model_catalog(super::OwnerAgcModelCatalog { + catalog, + access: module_runtime::AgcModelAccess::Full, + }) + .unwrap(), + ) + .unwrap(); // 客户端拿到稳定标识 + 别名(别名就是上游原始模型名),实际模型名不下发。 assert_eq!( payload["models"], @@ -66,6 +78,44 @@ mod model_catalog_tests { assert!(payload["models"][0].get("enabled").is_none()); assert!(payload["models"][0].get("modelId").is_none()); } + + #[test] + fn public_catalog_filters_full_models_for_basic_plan() { + let mut catalog = module_runtime::AgcModelCatalog::from_upstream_models( + vec!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()], + 0, + ) + .expect("catalog should build"); + // 登记的默认项是 Full;Basic 档看不到它,默认项回退到目录顺序里第一个可用项。 + catalog.models[0].access = module_runtime::AgcModelAccess::Full; + let payload = serde_json::to_value( + public_model_catalog(super::OwnerAgcModelCatalog { + catalog, + access: module_runtime::AgcModelAccess::Basic, + }) + .unwrap(), + ) + .unwrap(); + assert_eq!( + payload["models"], + json!([{"id": "gpt-5-6-terra", "displayName": "gpt-5.6-terra", "agentMode": "codex", "protocol": "openai_responses"}]) + ); + assert_eq!(payload["defaultModelId"], "gpt-5-6-terra"); + } + + #[test] + fn plan_violation_maps_to_forbidden_with_dedicated_code() { + let violation = + agc_model_resolve_error(module_runtime::AgcModelResolveError::NotAvailableForPlan); + assert_eq!(violation.status_code(), StatusCode::FORBIDDEN); + assert_eq!( + violation.code(), + module_runtime::MODEL_NOT_AVAILABLE_FOR_PLAN + ); + let unavailable = + agc_model_resolve_error(module_runtime::AgcModelResolveError::Unavailable); + assert_eq!(unavailable.status_code(), StatusCode::UNPROCESSABLE_ENTITY); + } } #[cfg(test)] @@ -195,32 +245,45 @@ pub async fn list_llm_models( Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result { - let catalog = load_llm_catalog(&state, authenticated.claims().user_id()) + let owner_catalog = load_owner_llm_catalog(&state, authenticated.claims().user_id()) .await .map_err(|error| llm_error_response(&request_context, error))?; - Ok(json_success_body(Some(&request_context), public_model_catalog(catalog)).into_response()) + let catalog = public_model_catalog(owner_catalog) + .map_err(|error| llm_error_response(&request_context, error))?; + Ok(json_success_body(Some(&request_context), catalog).into_response()) } -fn public_model_catalog(catalog: module_runtime::AgcModelCatalog) -> LlmModelsResponse { - LlmModelsResponse { - default_model_id: catalog.default_model_id, - models: catalog - .models - .into_iter() - .filter(|model| model.enabled) - .map(|model| { - let protocol = model.resolved_protocol().as_str().to_string(); - LlmModelSummary { - id: model.id, - // 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。 - display_name: model.alias, - agent_mode: model.agent_mode.as_str().to_string(), - protocol, - } - }) - .collect(), +fn public_model_catalog( + owner_catalog: OwnerAgcModelCatalog, +) -> Result { + let OwnerAgcModelCatalog { catalog, access } = owner_catalog; + // 该档一个可用模型都没有属于后台目录配置问题:明确报错,而不是下发一个不可解析的默认项。 + let default_model_id = catalog + .default_model_id_for(access) + .ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_code("MODEL_UNAVAILABLE") + .with_message("当前会员档位没有可用模型") + })? + .to_string(); + let models = catalog + .available_models_for(access) + .map(|model| { + let protocol = model.resolved_protocol().as_str().to_string(); + LlmModelSummary { + id: model.id.clone(), + // 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。 + display_name: model.alias.clone(), + agent_mode: model.agent_mode.as_str().to_string(), + protocol, + } + }) + .collect(); + Ok(LlmModelsResponse { + default_model_id, + models, revision: catalog.revision, - } + }) } /// 测试用目录:两项。上游模型名带 `.`,标识是它的 slug —— 既验证「客户端只回传目录标识」, @@ -262,6 +325,27 @@ async fn load_llm_catalog( crate::agc_models::load_catalog(state).await } +/// 全局目录 + 账号权限档;所有对外 LLM 路由都经它解析模型。 +async fn load_owner_llm_catalog( + state: &AppState, + owner: &str, +) -> Result { + let catalog = load_llm_catalog(state, owner).await?; + let access = resolve_owner_agc_model_access(state, owner).await; + Ok(OwnerAgcModelCatalog { catalog, access }) +} + +/// 模型解析错误的统一映射:档位不够是 403 + 专用错误码,目录外 / 停用仍是 422。 +fn agc_model_resolve_error(error: AgcModelResolveError) -> AppError { + let status = match error { + AgcModelResolveError::NotAvailableForPlan => StatusCode::FORBIDDEN, + AgcModelResolveError::Unavailable => StatusCode::UNPROCESSABLE_ENTITY, + }; + AppError::from_status(status) + .with_code(error.code()) + .with_message(error.message()) +} + /// Proxies the OpenAI-compatible Responses protocol for the LLM Router. /// /// The caller only presents the platform access token. The Router credential @@ -334,7 +418,7 @@ pub async fn proxy_llm_responses( .get("x-genarrative-client") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value == "agc"); - let catalog = load_llm_catalog(&state, authenticated.claims().user_id()) + let catalog = load_owner_llm_catalog(&state, authenticated.claims().user_id()) .await .map_err(|error| llm_error_response(&request_context, error))?; let requested_model = if agc_client { @@ -343,13 +427,9 @@ pub async fn proxy_llm_responses( None }; let selected_model = catalog - .resolve_requested(requested_model) - .map_err(|message| { - llm_error_response( - &request_context, - AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_message(message), - ) - })? + .catalog + .resolve_requested_for(requested_model, catalog.access) + .map_err(|error| llm_error_response(&request_context, agc_model_resolve_error(error)))? .to_string(); object.insert("model".to_string(), Value::String(selected_model.clone())); @@ -550,21 +630,17 @@ pub async fn proxy_llm_messages( ] { object.remove(field); } - let catalog = load_llm_catalog(&state, authenticated.claims().user_id()) + let catalog = load_owner_llm_catalog(&state, authenticated.claims().user_id()) .await .map_err(|error| llm_error_response(&request_context, error))?; // 这条路由只服务 AGC 的 Anthropic 执行器。Claude Agent SDK 直连网关、不会补 // `x-genarrative-client` 标记,所以这里直接按目录解析客户端选的稳定标识; - // 目录外取值仍由 `resolve_requested` 拒绝。 + // 目录外取值与档位不足都由 `resolve_requested_for` 拒绝。 let requested_model = requested_model.as_deref(); let selected_model = catalog - .resolve_requested(requested_model) - .map_err(|message| { - llm_error_response( - &request_context, - AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_message(message), - ) - })? + .catalog + .resolve_requested_for(requested_model, catalog.access) + .map_err(|error| llm_error_response(&request_context, agc_model_resolve_error(error)))? .to_string(); // 原生直通要用目录解析后的上游模型名;桥接路径由转换器显式接收同一个名字。 object.insert("model".to_string(), Value::String(selected_model.clone())); @@ -1402,10 +1478,14 @@ async fn resolve_llm_router_client( owner_user_id: &str, ) -> Result<(platform_llm::LlmClient, String), String> { let (base_url, api_key, key_id) = resolve_llm_router_credentials(state, owner_user_id).await?; - let catalog = load_llm_catalog(state, owner_user_id) + let catalog = load_owner_llm_catalog(state, owner_user_id) .await .map_err(|_| "模型目录暂不可用".to_string())?; - let model = catalog.resolve_requested(None)?; + // Chat Completions 的 `model` 恒为账号该档的默认模型,不接收客户端选择。 + let model = catalog + .catalog + .resolve_requested_for(None, catalog.access) + .map_err(|error| error.message().to_string())?; let config = platform_llm::LlmConfig::new( platform_llm::LlmProvider::OpenAiCompatible, base_url.to_string(), diff --git a/server-rs/crates/api-server/src/llm/model_access.rs b/server-rs/crates/api-server/src/llm/model_access.rs new file mode 100644 index 000000000..65716b042 --- /dev/null +++ b/server-rs/crates/api-server/src/llm/model_access.rs @@ -0,0 +1,55 @@ +//! 账号会员档位 → AGC 模型权限档的解析(BFF 侧,带 IO)。 +//! +//! 模型目录本身是全局的,权限判定在 `module-runtime`;这里只负责把「当前账号」解析成 +//! [`AgcModelAccess`],再把全局目录与该档位打包给路由层使用。 +//! +//! 失败关闭:会员行缺失、目录行缺失、或 SpacetimeDB 读失败时都按 `Normal`(`Basic`)处理, +//! 只放宽到基础模型,不放开高性能模型。 + +use crate::state::AppState; +use module_runtime::{AgcModelAccess, AgcModelCatalog}; + +/// 账号视角的 AGC 模型目录:全局目录 + 该账号的权限档。 +pub(crate) struct OwnerAgcModelCatalog { + pub(crate) catalog: AgcModelCatalog, + pub(crate) access: AgcModelAccess, +} + +/// 解析账号的 AGC 模型权限档。 +/// +/// 走现有 `get_profile_recharge_center` 读模型(它同时返回会员行与档位目录行); +/// 该读带幂等的账期刷新写入,后续可替换为更轻的专用读。 +pub(crate) async fn resolve_owner_agc_model_access( + state: &AppState, + owner_user_id: &str, +) -> AgcModelAccess { + #[cfg(test)] + if super::test_provisioned_router_credentials() + .lock() + .expect("fixture lock") + .contains_key(owner_user_id) + { + // 测试夹具目录里的模型全是缺省 `Basic`,返回 `Full` 以避免夹具再去连 SpacetimeDB。 + return AgcModelAccess::Full; + } + match state + .spacetime_client() + .get_profile_recharge_center(owner_user_id.to_string()) + .await + { + Ok(center) => center + .membership_plans + .iter() + .find(|row| row.plan == center.membership.plan) + .map(|row| AgcModelAccess::from(row.model_access)) + .unwrap_or(AgcModelAccess::Basic), + Err(error) => { + tracing::warn!( + user_id = %owner_user_id, + error = %error, + "读取会员档位失败,AGC 模型权限按 Basic 失败关闭" + ); + AgcModelAccess::Basic + } + } +} From 413b3645c21d464accc5c6965760abfb871255ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 16:39:01 +0800 Subject: [PATCH 139/232] =?UTF-8?q?docs(=E4=BC=9A=E5=91=98):=20=E8=A1=A5?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=9D=83=E9=99=90=E4=B8=8E=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E4=B8=8A=E9=99=90=E7=9A=84=E5=AE=9E=E7=8E=B0=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E4=B8=8E=E5=A5=91=E7=BA=A6=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据契约 external_generation_job 增补 2026-10-03 会员并发上限:新增 (owner_user_id, status) 索引、认领只算 running、过期回收豁免、缺行失败关闭到 Normal - 设计文档 §11.4 增补落地状态与提交号,并标注账号档位轻量读与客户端 403 重选提示为后续可选项 - decision-log 追加落地实现条目:实现拆分、Basic/Full 与 Normal 的失败语义、验证结论与边界 TODO --- docs/project-memory/shared-memory/decision-log.md | 11 +++++++++++ ...�技术设计】泥点三池与会员计费后端设计-2026-10-02.md | 1 + ...端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md | 1 + 3 files changed, 13 insertions(+) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6d0c7a9d0..cafcb0a0e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9631,6 +9631,17 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响范围:`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md`(新增 §11,修订 §9/§10)、`CONTEXT.md`(并发上限 / 新增「独立生成任务」)、`docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md`、`docs/README.md`。未改代码。 - 验证:`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 +## 2026-10-03 模型权限与并发上限落地实现 + +- 背景:承接同日「模型权限与并发上限的强制边界(拷问定案)」,把定案按 §11.4 编码约定落地;本文记实现拆分与验证,不再重复决策理由。 +- 实现(`module-runtime`,`b78003143`):新增 `agc_model_access.rs`(`AgcModelAccess::Basic/Full`、`MODEL_NOT_AVAILABLE_FOR_PLAN`、`AgcModelResolveError::{Unavailable, NotAvailableForPlan}`、`RuntimeProfileMembershipModelAccess → AgcModelAccess`);`AgcModel` 末位追加 `#[serde(default)] access`(存量 JSON 缺字段即 `Basic`,失败开放);`AgcModelCatalog` 增加 `available_models_for / default_model_id_for / resolve_requested_for`,默认模型优先取登记项、不在档内则回退目录顺序首个可用项、无可用项返回 `Unavailable`。 +- 实现(`spacetime-module`,`db8d467b8`):`external_generation_job` 追加 btree 组合索引 `(owner_user_id, status)`;`claim_external_generation_jobs_tx` 在 `pending → running` 前按 owner 现算 `running` 行数并过滤,达上限留在 `pending`;只算 `running`(含过期待回收),自身过期 `running` 回收豁免,`pending` 不占名额;`effective_profile_concurrent_job_limit` 走 `profile_membership.plan → concurrent_job_limit`,缺行失败关闭到 `Normal`(=1),`128` 为不限;抽取纯函数 `external_generation_claim_within_concurrency_limit` 并补单测。 +- 实现(后台,`919b4b01d`):`AdminAgcModel` 新增 `access`(缺省 `basic`),api-server 保存时只接受 `basic/full`,`GET` 回读 / `PUT` 写入;后台「AGC 模型」页新增「权限档」列与选择器,fake API 与页面用例覆盖。 +- 实现(`api-server`,`9b1a538d2`):新增 `llm/model_access.rs`,用现有 `get_profile_recharge_center` 解析账号档位到 `AgcModelAccess`,缺会员行 / 缺目录行 / 读失败统一失败关闭到 `Basic`;`GET /api/llm/models` 按档过滤且默认项取该档可用集合;`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全改按档解析;越权映射 403 + `MODEL_NOT_AVAILABLE_FOR_PLAN`,目录外 / 停用仍为 422。 +- 术语口径:`Basic` / `Full` 是模型对会员档的要求,`Normal` 等是会员档位;缺省语义统一「失败开放到基础档、失败关闭到 1 并发」——模型档缺省 `Basic` 是失败开放(不拦),并发上限缺行失败关闭到 `Normal`(=1)。 +- 验证:`cargo check -p api-server`、`cargo check -p api-server --tests`;`cargo test -p api-server llm` 49 passed、`model_catalog_tests` 3 passed(含 Basic 档过滤 Full 模型、403 错误码映射);`npm run check:spacetime-schema` 的失败项全部是分支既有 `profile_membership` / `profile_recharge_product_config` 字段变更(对照 `origin/master` merge-base `25beb3ad5`),与本次仅新增 btree 索引无关,guard 不比较索引;`npm run check:encoding`、`git diff --check`。 +- 边界 / TODO:账号档位仍复用带周期刷新写入的 `get_profile_recharge_center`,后续替换为轻量专用读;AGC 客户端仍靠「目录按档过滤 + 本地选择对账回退默认项」,未加专门的 403 重选提示;统一「账号在飞生成」口径(含无 job 行的同步生成)仍为后续 TODO。 + ## 2026-10-03 会员链路错误分类改为机器可读错误码(typed error) - 背景:`api-server/src/runtime_profile.rs` 的 `is_runtime_profile_membership_domain_error` 用中文 `error_message` 前缀 / 精确匹配决定返回 400 还是 502;文案一改(或新增拒绝点)就静默退化,且分类逻辑与 module 的错误文案跨层重复。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index 46cc93b20..182072df7 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -609,6 +609,7 @@ flowchart TD - **`api-server`**:新增 `llm/model_access.rs`,用现有 `get_profile_recharge_center` 读出账号 `membership.plan` 与目录行 `model_access` 并映射到 `AgcModelAccess`;`GET /api/llm/models` 按档过滤,`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全部按档解析,越权返回 403 + 错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`。 - **后台**:`AdminAgcModel` 与后台「AGC 模型」页新增 `access` 字段(`basic` / `full`,缺省 `basic`),GET 回读、PUT 写入;**不做**自动播种或代码内清单。 - **性能说明**:本期账号档位复用现有 `get_profile_recharge_center` 读模型,它带幂等的周期刷新写入;后续可替换为更轻的专用读(TODO)。 +- **落地状态(2026-10-03)**:`module-runtime` 权限档与目录解析 `b78003143`;`spacetime-module` 并发索引与认领过滤 `db8d467b8`;后台 `access` 标注与页面 `919b4b01d`;`api-server` 按档过滤 / 校验 `9b1a538d2`。AGC 客户端侧仍以「目录按档过滤 + 本地选择对账回退默认项」为主,未额外新增针对 403 `MODEL_NOT_AVAILABLE_FOR_PLAN` 的专门提示,作为后续可选项。 --- diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index f51a57277..9cd812614 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -286,6 +286,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`ExternalGenerationJob` - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` - 现役覆盖:worker claim 只允许 `source_module = editor-canvas`;任务领取、续租、阶段更新和结果完成必须携带并校验同一 `worker_id + lease_token`。 +- 2026-10-03 会员并发上限:新增 btree 组合索引 `by_external_generation_job_owner_status (owner_user_id, status)`。`claim_external_generation_jobs_tx` 在 `pending → running` 前按任务 owner 的 `profile_membership.plan → profile_membership_plan.concurrent_job_limit` 现算该 owner `status = running` 的行数(含租约过期待回收的 `expired_running`),达上限的 pending 保持排队;账号自身过期的 running 回收豁免上限,避免超限账号的卡死任务无法回收。`pending` 不占名额,失败退回 `pending` 会自然释放名额;不新增计数表。缺会员行按 `Normal`(上限 1)失败关闭,目录行缺失同样按 1;`128` 哨兵由 `is_unlimited_concurrency` 解释为不限。 - 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写阶段、完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,末尾可选 `phase` 只取 `generating / processing`;claim 写 `generating`,真实进入抠图处理时由受 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。phase procedure 以结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`;`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,只有 `Build` / `ConnectDropped` / `Timeout` 在同一个 job attempt 内重试一次。该重试只重新上报 phase,不把任务写回 `pending`,也不重新调用 provider;编辑器 job 入队固定 `max_attempts=1`,第二次传输失败后任务进入 `failed`,不会回到 `pending` 或从 provider 生成起点重跑。用户可见任务列表、价格、状态、阶段、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表。结构化 canvas 激活后,当前 worker completion 先以读取时 canvas revision 执行 CAS,并发冲突时拒绝覆盖并保留可诊断失败;目标是进一步收口为受 lease 栅栏保护的单事务幂等写入 `editor_project_resource`、结果 `editor_canvas_layer`、`editor_canvas_generation_dialog` 终态和 canvas revision。未激活 canvas 在 2 MiB 上限内继续走 `editor_canvas.layers_json` 兼容写回。前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 - 2026-08-06 收口覆盖:上一条用途描述中“当前先 CAS、单事务仍是目标”的旧句已作废。现役编辑器生成不再组合调用 object confirm、resource create、asset create、canvas save 和 job complete。`api-server` 只准备稳定候选,再经 `spacetime-client` 调用 `persist_editor_generation_result_and_return`;procedure 在同一 `try_with_tx` 内写入可选 `asset_object`、全部 `editor_project_resource`、`editor_asset`、可选 `asset_entity_binding`、可选 canvas V2 CAS、queue job 终态和 `editor_generation_operation` receipt。结构化 canvas 的 layer / dialog / revision 与未激活 canvas 的 legacy `layers_json` 仍经既有 V2 布局验证分流,前端不直接发明正式完成态。queue 首次提交在同一快照验证 owner、job kind、request fingerprint 和有效 `job_id + worker_id + lease_token`;统一 procedure 已完成 job 后 worker 不得再单独 complete。 - 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用。画布 Agent 来源的任务可在 `result_payload_json.editor-agent-tool-call-result` 中保存有界的轻量结果和已登记媒体引用,供后端按已有 `externalJobId + owner_user_id` 定向懒回填;其它编辑器任务保持元数据结果,并可保存有界的 `warning.code/reason`。未登记的 source module 不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行、受控维护以及画布 Agent 的定向结果回填读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得返回或解析这两个 payload。画布 Agent 懒回填必须经对应工具 formatter 归一为有界轻量媒体引用后写入 OSS 会话,不能把原始 payload 直接透传前端。 From 1d09d7e061e534399aec3d51589c40a89e95e81e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 17:23:19 +0800 Subject: [PATCH 140/232] =?UTF-8?q?perf(=E4=BC=9A=E5=91=98):=20=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E7=94=9F=E6=88=90=E8=AE=A4=E9=A2=86=E6=8C=89=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E6=8A=98=E5=8F=A0=E5=B9=B6=E5=8F=91=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E4=B8=8E=E5=9C=A8=E9=A3=9E=E8=AE=A1=E6=95=B0=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ExternalGenerationOwnerConcurrencyCache:单次 claim 事务内按账号只查一次 concurrent_job_limit,running 计数按账号缓存 - 认领 / lease 耗尽终结后失效该账号 running 缓存,判定结果与逐候选行现查一致 - 回收豁免与不限档不再触发 running 计数查询;补缓存失效单测 --- .../src/external_generation.rs | 86 ++++++++++++++++--- 1 file changed, 76 insertions(+), 10 deletions(-) diff --git a/server-rs/crates/spacetime-module/src/external_generation.rs b/server-rs/crates/spacetime-module/src/external_generation.rs index ab09c27c3..91f0758ba 100644 --- a/server-rs/crates/spacetime-module/src/external_generation.rs +++ b/server-rs/crates/spacetime-module/src/external_generation.rs @@ -1,5 +1,6 @@ use crate::*; use std::cmp::Ordering; +use std::collections::HashMap; use std::ops::RangeFrom; const EXTERNAL_GENERATION_STATUS_PENDING: &str = "pending"; @@ -885,14 +886,23 @@ fn claim_external_generation_jobs_tx( }); let mut claimed = Vec::new(); + let mut concurrency = ExternalGenerationOwnerConcurrencyCache::default(); for mut row in candidates.into_iter().take(limit) { if external_generation_job_has_exhausted_attempts(&row) { + let owner_user_id = row.owner_user_id.clone(); finalize_external_generation_job_after_lease_exhaustion(ctx, row, claim_time)?; + // 终结可能把 running 行改成 failed,账号计数缓存随之失效。 + concurrency.invalidate_running(&owner_user_id); continue; } // 会员并发上限:只约束 `pending → running` 的认领。达上限的任务留在 `pending` 天然排队; // 账号自己那条已过期的 `running` 回收不受限,否则超限账号的卡死任务永远无法回收。 - if !external_generation_owner_has_concurrency_capacity(ctx, &row, claim_time) { + if !external_generation_owner_has_concurrency_capacity( + ctx, + &mut concurrency, + &row, + claim_time, + ) { continue; } let next_attempt = row.attempt.saturating_add(1); @@ -921,33 +931,77 @@ fn claim_external_generation_jobs_tx( Some(worker_id.clone()), claim_time, ); + // 本次认领把一行推进到 running,账号计数缓存失效,后续候选行重新现查。 + concurrency.invalidate_running(&row.owner_user_id); claimed.push(map_external_generation_job_row(row)); } Ok(claimed) } +/// 单次 claim 事务内按账号缓存有效并发上限与 running 行数。 +/// +/// 上限在一次事务内是常量;running 计数在账号的任一行被认领或终结后立即失效、下次重查, +/// 因此判定结果与逐候选行现查一致,只是把同一账号的重复查询折叠为一次。 +#[derive(Default)] +struct ExternalGenerationOwnerConcurrencyCache { + limits: HashMap, + running: HashMap, +} + +impl ExternalGenerationOwnerConcurrencyCache { + fn limit(&mut self, ctx: &ReducerContext, owner_user_id: &str) -> u32 { + if let Some(limit) = self.limits.get(owner_user_id) { + return *limit; + } + let limit = crate::effective_profile_concurrent_job_limit(ctx, owner_user_id); + self.limits.insert(owner_user_id.to_string(), limit); + limit + } + + fn running_count(&mut self, ctx: &ReducerContext, owner_user_id: &str) -> u32 { + if let Some(count) = self.running.get(owner_user_id) { + return *count; + } + let count = count_running_external_generation_jobs_for_owner(ctx, owner_user_id); + self.running.insert(owner_user_id.to_string(), count); + count + } + + /// 账号任一 `running` 行发生状态变化(认领 / 终结)后调用,保证下次读取重新现查。 + fn invalidate_running(&mut self, owner_user_id: &str) { + self.running.remove(owner_user_id); + } + + fn allows(&mut self, ctx: &ReducerContext, owner_user_id: &str, is_recycling: bool) -> bool { + let limit = self.limit(ctx, owner_user_id); + if is_unlimited_concurrency(limit) { + return true; + } + // 回收豁免不需要计数:自身过期 running 已计入在飞数,放行不会推高它。 + let running = if is_recycling { + 0 + } else { + self.running_count(ctx, owner_user_id) + }; + external_generation_claim_within_concurrency_limit(is_recycling, running, limit) + } +} + /// 认领前的并发上限判定。 /// /// 计数只算 `status = running`(含 lease 过期待回收的 `expired_running`):`pending`(含延时重试) /// 不占名额,失败退回 `pending` 会自然释放。`128` 哨兵由 [`is_unlimited_concurrency`] 解释。 fn external_generation_owner_has_concurrency_capacity( ctx: &ReducerContext, + cache: &mut ExternalGenerationOwnerConcurrencyCache, row: &ExternalGenerationJob, now: Timestamp, ) -> bool { - let limit = crate::effective_profile_concurrent_job_limit(ctx, &row.owner_user_id); - if is_unlimited_concurrency(limit) { - return true; - } // 回收豁免:候选本身已是该账号过期的 `running`,它已计入在飞数;放行回收不会推高在飞任务数。 let is_recycling = row.status == EXTERNAL_GENERATION_STATUS_RUNNING && is_external_generation_job_claimable(row, now); - external_generation_claim_within_concurrency_limit( - is_recycling, - count_running_external_generation_jobs_for_owner(ctx, &row.owner_user_id), - limit, - ) + cache.allows(ctx, &row.owner_user_id, is_recycling) } /// 并发上限判定(纯函数):`running_count >= limit` 时只放行对账号自身过期 `running` 的回收。 @@ -3936,6 +3990,18 @@ mod tests { )); } + #[test] + fn owner_concurrency_cache_only_invalidates_running_count() { + let mut cache = ExternalGenerationOwnerConcurrencyCache::default(); + cache.running.insert("owner-a".to_string(), 2); + cache.limits.insert("owner-a".to_string(), 3); + // 认领 / 终结后 running 计数必须重查,否则会把上一行的变更带到下一候选行。 + cache.invalidate_running("owner-a"); + assert!(cache.running.get("owner-a").is_none()); + // 上限在一次事务内是常量,不受 running 计数失效影响。 + assert_eq!(cache.limits.get("owner-a"), Some(&3)); + } + fn micros(value: i64) -> Timestamp { Timestamp::from_micros_since_unix_epoch(value) } From e91abf9875e6d2e881012db77dca6ce1ccc3d921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 17:46:49 +0800 Subject: [PATCH 141/232] =?UTF-8?q?docs(=E4=BC=9A=E5=91=98):=20=E5=AE=9A?= =?UTF-8?q?=E6=A1=88=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8=E5=8F=AF=E7=94=A8?= =?UTF-8?q?/=E4=B8=8D=E5=8F=AF=E7=94=A8=E5=88=86=E6=A1=B6=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR 增补修订节:GET /api/llm/models 返回 models + unavailableModels,reason 枚举 plan_required/disabled/unknown,停用优先,不放松服务端强制 - 技术设计 §11.2 改述列表契约为分桶,§11.4 补 llm_catalog.rs 落点、域层分桶方法、AGC 客户端 hover 文案与 ts-rs 单一真源,并刷新落地提交号 - AGC 后台模型别名技术方案补 2026-10-03 修订说明 - AGC 模型目录上游同步里程碑标注「形状不变」已被后续变更取代 - decision-log 追加分桶决策、兼容与客户端口径 --- .../【ADR】模型权限与并发上限的强制边界-2026-10-03.md | 11 +++++++++++ .../plans/【里程碑】AGC模型目录上游同步-2026-09-24.md | 2 ++ docs/project-memory/shared-memory/decision-log.md | 10 ++++++++++ ...€�技术方案】AGC后台模型别名与对话选择-2026-09-05.md | 1 + ...�技术设计】泥点三池与会员计费后端设计-2026-10-02.md | 7 +++++-- 5 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md b/docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md index c817b6756..658f92635 100644 --- a/docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md +++ b/docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md @@ -24,3 +24,14 @@ accepted(2026-10-03) - 模型限制在人工标注 `Full` 之前不会拦截任何请求。 - `raw` gpt-image-2 与其它直连同步生成不受 `concurrent_job_limit` 约束(TODO)。 - 无灰度开关;出问题只能回滚发版。 + +## 修订:列表改为可用 / 不可用分桶(2026-10-03) + +原实现让 `GET /api/llm/models` 只返回本档可用模型(`Basic` 看不到 `Full`),降级用户在客户端只会看到「所选模型已停用」的笼统提示,且产品无法在列表里露出可升级的高档模型。修订为: + +- 返回两个平行数组:`models` 只放本档可用(`enabled` 且档位允许);`unavailableModels` 放目录里其余全部,元素额外带 `reason` 枚举。 +- `reason` 取值 `plan_required`(档位不够)/ `disabled`(后台停用)/ `unknown`(前向兜底);同时停用且档位不够时取 `disabled`(升级也解锁不了,不能标 `plan_required`)。 +- 上游真实模型名(`modelId`)两桶都不下发;`defaultModelId` 必须落在 `models` 内。 +- 分桶只是展示层的信息补充,**不放松服务端强制**:`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 仍按档 4xx。 +- `unavailableModels` 是加法字段:旧客户端忽略后行为不变;`reason` 在 wire 侧带 `unknown` 兜底,后端新增取值不会让旧客户端解析失败。 +- 目录 DTO 与枚举改为 ts-rs 单一真源(whole DTO 导出),`agentMode` / `protocol` 由 `String` 收敛为 Rust 枚举。 diff --git a/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md b/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md index 3c14f4a5e..327ac50b1 100644 --- a/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md +++ b/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md @@ -17,6 +17,8 @@ 2. 拉不到就报错、不写替代目录,并在下一次启动继续重试,直到目录里有数据。 3. **保持既有格式与契约不变**:目录字段(`id`/`alias`/`modelId`/`defaultModelId`)、后台页面与 DTO、`GET /api/llm/models` 形状、客户端模型标识校验都不变,不引入不兼容变更。 +> 2026-10-03 修订:本条「`GET /api/llm/models` 形状不变」只约束本里程碑自身;后续「模型权限」变更已把该接口改为可用 / 不可用分桶(新增 `unavailableModels` 与 `reason` 枚举),见 `docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md` 的修订节。 + ## 不在本里程碑内 - 不改目录字段语义与后台维护方式,不删别名/稳定标识概念。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index cafcb0a0e..ab10fcd52 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9642,6 +9642,16 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证:`cargo check -p api-server`、`cargo check -p api-server --tests`;`cargo test -p api-server llm` 49 passed、`model_catalog_tests` 3 passed(含 Basic 档过滤 Full 模型、403 错误码映射);`npm run check:spacetime-schema` 的失败项全部是分支既有 `profile_membership` / `profile_recharge_product_config` 字段变更(对照 `origin/master` merge-base `25beb3ad5`),与本次仅新增 btree 索引无关,guard 不比较索引;`npm run check:encoding`、`git diff --check`。 - 边界 / TODO:账号档位仍复用带周期刷新写入的 `get_profile_recharge_center`,后续替换为轻量专用读;AGC 客户端仍靠「目录按档过滤 + 本地选择对账回退默认项」,未加专门的 403 重选提示;统一「账号在飞生成」口径(含无 job 行的同步生成)仍为后续 TODO。 +## 2026-10-03 模型列表改为可用 / 不可用分桶(修订) + +- 背景:`GET /api/llm/models` 原按档过滤,降级用户在客户端只看到「所选模型已停用」的笼统提示,且无法在列表里露出可升级的高档模型。 +- 决策(契约):返回 `models`(本档可用)与 `unavailableModels`(目录里其余全部,带 `reason` 枚举)两个平行数组,**不新增** `available` 布尔;`reason` 取 `plan_required` / `disabled` / `unknown`,同时停用且档位不够取 `disabled`;`unavailableModels` 保持目录顺序、恒定下发(无内容为空数组);上游真实模型名两桶都不下发;`defaultModelId` 必须落在 `models` 内;某档一个可用模型都没有时保留 503 `MODEL_UNAVAILABLE`。 +- 决策(兼容):`unavailableModels` 是加法字段,旧客户端忽略后行为不变,无需能力协商;`reason` 在 wire 侧带 `unknown` 兜底,后端将来新增取值不会让旧客户端解析失败;服务端按档强制不变。 +- 决策(客户端):AGC 桌面选择器把 `unavailableModels` 渲染为不可点项,仅 hover / 键盘 focus 出 tooltip(`plan_required`→「订阅计划不支持」、`disabled`→「该模型已下线」、`unknown`→「暂不可用」);降级仍自动回退默认,提示按 `reason` 取;后台直接删除(两桶都没有)用泛化文案「所选模型已不可用,已切回默认模型」;不再新增「当前已选不可用」的 API 字段,前端自行从 `unavailableModels` 推断。 +- 决策(ts-rs 单一真源):整个目录 DTO 与枚举导出到 `packages/shared/src/contracts/generated/`;新增 barrel `packages/shared/src/llm/modelCatalog.ts` 并由 `packages/shared/src/index.ts` 转发;`AgcAgentMode` / `AgcModelProtocol` 从 `module-runtime` 迁入 `shared-contracts`(`module-runtime` re-export);`revision: u64` 标 `#[ts(as = "f64")]`;AGC 前端删除手写 `ClientLlmModel`,改从 `@genarrative/shared` 根导入。 +- 影响范围:`docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md`(修订节)、技术设计 §11.2/§11.4、`docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`、`docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md`、本文件。 +- 验证:`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 + ## 2026-10-03 会员链路错误分类改为机器可读错误码(typed error) - 背景:`api-server/src/runtime_profile.rs` 的 `is_runtime_profile_membership_domain_error` 用中文 `error_message` 前缀 / 精确匹配决定返回 400 还是 502;文案一改(或新增拒绝点)就静默退化,且分类逻辑与 module 的错误文案跨层重复。 diff --git a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md index d14ae6087..eb2e3cc57 100644 --- a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md +++ b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md @@ -42,6 +42,7 @@ - 上游变化不自动跟随:目录只在未初始化时重建;上游新增或移除模型由 owner 在后台增删条目或调整启用、默认项。 - `GET/PUT /admin/api/agc-models` 仅 owner 可用,返回完整配置;PUT 携带上次读取的 revision,冲突拒绝覆盖。 - `GET /api/llm/models` 返回启用项的 `id/displayName`、`defaultModelId` 和目录 `revision`,不返回实际模型名、Router 目录、凭据或能力原始数据。 +- 2026-10-03 修订(模型权限):`GET /api/llm/models` 改为两个平行数组——`models` 只放本档可用;`unavailableModels` 放目录里其余(含后台停用与本档档位不够),元素带 `reason` 枚举(`plan_required` / `disabled` / `unknown`,同时停用且档位不够取 `disabled`)。`unavailableModels` 是加法字段,旧客户端忽略后行为不变,且不放松服务端按档强制;`defaultModelId` 仍必须落在 `models` 内。客户端把 `unavailableModels` 渲染为不可点项并 hover / focus 出原因(`plan_required`→「订阅计划不支持」、`disabled`→「该模型已下线」、`unknown`→「暂不可用」);已选模型落进不可用桶时自动回退默认并给出对应原因文案,后台直接删除(两桶都没有)用泛化文案。 - 客户端缓存最近 `revision`,在项目切换 / 对话表面挂载 / 下拉展开 / 窗口聚焦时条件刷新:`revision` 未变化不更新界面,同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择。发起对话前用同一份快照校验所选模型仍启用,已停用或删除则回退默认模型并提示。 - 手动刷新立即显示进行中状态;真实刷新成功后显示完成反馈,即使 `revision` 未变化也有反馈。失败沿用有效缓存时仍显示失败,不能报告刷新成功;HTTP 状态和超时使用可辨认的提示。 - 模型目录与其它客户端 JSON API 的成功、失败响应体读取均复用 `readClientHttpResponseText` 的 15 秒上限;响应头已返回但响应体卡住时必须结束本次等待、释放目录在途请求并允许重试,迟到的响应不得覆盖新目录。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index 182072df7..d6826571b 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -588,7 +588,7 @@ flowchart TD - **标注**:由**人工在后台「AGC 模型」页手动**把高性能模型标为 `Full`;代码里**不**写死清单、不播种。 - **档位解析**:`load_llm_catalog(owner)` 已带 owner;由 `profile_membership.plan` → 目录 `model_access` 解析;缺档位失败关闭到 `Normal`(`Basic`)。 - **生效点**: - - `GET /api/llm/models`:按账号档位过滤(`Basic` 档只看到非 `Full` 模型),`defaultModelId` 取该档可用集合的默认项(见 §11.4)。 + - `GET /api/llm/models`:返回 `models`(本档可用)与 `unavailableModels`(目录里其余,元素带 `reason` 枚举 `plan_required` / `disabled` / `unknown`;同时停用且档位不够取 `disabled`),`defaultModelId` 取该档可用集合的默认项(见 §11.4)。分桶只补充展示信息,不放松下述强校验。 - `POST /api/llm/responses`:`catalog.resolve_requested()` 映射后校验档位,越权返回 4xx 专用错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`。 - `POST /api/llm/chat/completions`(`model` 恒为默认)与 anthropic bridge:只需保证解析出的默认模型落在该档可用集合内。 - 客户端本地残留旧选择(`select_game_creator_model` 持久化)→ **明确 4xx**,由客户端提示重选;**不静默回退**。 @@ -609,7 +609,10 @@ flowchart TD - **`api-server`**:新增 `llm/model_access.rs`,用现有 `get_profile_recharge_center` 读出账号 `membership.plan` 与目录行 `model_access` 并映射到 `AgcModelAccess`;`GET /api/llm/models` 按档过滤,`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全部按档解析,越权返回 403 + 错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`。 - **后台**:`AdminAgcModel` 与后台「AGC 模型」页新增 `access` 字段(`basic` / `full`,缺省 `basic`),GET 回读、PUT 写入;**不做**自动播种或代码内清单。 - **性能说明**:本期账号档位复用现有 `get_profile_recharge_center` 读模型,它带幂等的周期刷新写入;后续可替换为更轻的专用读(TODO)。 -- **落地状态(2026-10-03)**:`module-runtime` 权限档与目录解析 `b78003143`;`spacetime-module` 并发索引与认领过滤 `db8d467b8`;后台 `access` 标注与页面 `919b4b01d`;`api-server` 按档过滤 / 校验 `9b1a538d2`。AGC 客户端侧仍以「目录按档过滤 + 本地选择对账回退默认项」为主,未额外新增针对 403 `MODEL_NOT_AVAILABLE_FOR_PLAN` 的专门提示,作为后续可选项。 +- **列表契约(2026-10-03 修订)**:`shared-contracts` 新增 `llm_catalog.rs` 承载 `LlmModelsResponse` / `LlmModelSummary` / `LlmUnavailableModel` / `LlmModelUnavailableReason`(`plan_required` / `disabled` / `unknown`);`AgcAgentMode` / `AgcModelProtocol` 从 `module-runtime` 迁入 `shared-contracts` 并由 `module-runtime` re-export;整个目录 DTO 与枚举用 `#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]` 导出到 `packages/shared/src/contracts/generated/`,`revision` 标 `#[ts(as = "f64")]`,AGC 前端从 `@genarrative/shared` 根导入,删掉手写的 `ClientLlmModel`。 +- **分桶来源**:`module-runtime` 新增 `AgcModelCatalog::unavailable_models_for(access)`(保持目录顺序,返回 `(&AgcModel, LlmModelUnavailableReason)`),`api-server` 只做 DTO 映射;`models` 仍走 `available_models_for`;`unavailableModels` 恒定下发(无内容时为空数组)。 +- **客户端**:AGC 桌面选择器把 `unavailableModels` 渲染为不可点项,hover / 键盘 focus 出 tooltip(`plan_required` → 「订阅计划不支持」、`disabled` → 「该模型已下线」、`unknown` → 「暂不可用」);降级仍自动回退默认,提示按 `reason` 取;后台直接删除(两桶都没有)用泛化文案「所选模型已不可用,已切回默认模型」。 +- **落地状态(2026-10-03)**:`module-runtime` 权限档与目录解析 `da9602839`;`spacetime-module` 并发索引与认领过滤 `df7682816`;后台 `access` 标注与页面 `54b02e228`;`api-server` 按档过滤 / 校验 `3aae77bc5`;认领按账号折叠并发查询 `c3c3fc5a2`。AGC 客户端侧原以「目录按档过滤 + 本地选择对账回退默认项」为主,本次修订为可用 / 不可用分桶。 --- From cf40df6be3d3842d05800da8f5649c476829011d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 17:49:59 +0800 Subject: [PATCH 142/232] =?UTF-8?q?feat(=E4=BC=9A=E5=91=98):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=8F=AA=E8=AF=BB=20AGC=20=E6=A8=A1=E5=9E=8B=E6=9D=83?= =?UTF-8?q?=E9=99=90=20procedure=20=E4=B8=8E=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E8=AE=BF=E9=97=AE=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module-runtime 新增 RuntimeProfileAgcModelAccessSnapshot / ProcedureResult 领域类型 - spacetime-module 新增 get_profile_agc_model_access_and_return:只读会员账期投影 + 档位目录 model_access,缺目录行失败关闭 Basic - 重新生成绑定并补 spacetime-client get_profile_agc_model_access 与结果映射 --- server-rs/crates/module-runtime/src/domain.rs | 20 +++++++ .../src/active/mapper/runtime_profile.rs | 16 +++++ .../spacetime-client/src/active/runtime.rs | 28 +++++++++ .../spacetime-client/src/module_bindings.rs | 6 ++ ...e_agc_model_access_and_return_procedure.rs | 59 +++++++++++++++++++ ..._agc_model_access_procedure_result_type.rs | 19 ++++++ ..._profile_agc_model_access_snapshot_type.rs | 20 +++++++ .../src/runtime/active/profile.rs | 49 +++++++++++++++ 8 files changed, 217 insertions(+) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_profile_agc_model_access_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_snapshot_type.rs diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 94860d76b..6477930ac 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1731,6 +1731,26 @@ pub struct RuntimeProfileMembershipSnapshotProcedureResult { pub error_message: Option, } +/// 账号当前有效的 AGC 模型权限档。 +/// +/// 与写库刷新的充值中心快照不同,它只读会员账期投影与档位目录,不触发换期 / 免费点刷新,供 LLM 代理热路径使用。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileAgcModelAccessSnapshot { + pub user_id: String, + pub plan: RuntimeProfileMembershipPlan, + pub model_access: RuntimeProfileMembershipModelAccess, +} + +/// 只读 AGC 模型权限 procedure 的结果:成功时 `record` 为解析后的权限档,失败时给出 `error_message`。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileAgcModelAccessProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileRechargeOrderSnapshot { diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs index 73a65cdc2..fda444938 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs @@ -779,6 +779,22 @@ pub(crate) fn map_runtime_profile_membership_snapshot_procedure_result( )) } +pub(crate) fn map_runtime_profile_agc_model_access_procedure_result( + result: RuntimeProfileAgcModelAccessProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + let record = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile AGC model access 快照"))?; + Ok(module_runtime::RuntimeProfileAgcModelAccessSnapshot { + user_id: record.user_id, + plan: map_runtime_profile_membership_plan_back(record.plan), + model_access: map_runtime_profile_membership_model_access_back(record.model_access), + }) +} + pub(crate) fn map_runtime_profile_recharge_order_procedure_result( result: RuntimeProfileRechargeCenterProcedureResult, ) -> Result< diff --git a/server-rs/crates/spacetime-client/src/active/runtime.rs b/server-rs/crates/spacetime-client/src/active/runtime.rs index 99e3f4bf6..dd1d32724 100644 --- a/server-rs/crates/spacetime-client/src/active/runtime.rs +++ b/server-rs/crates/spacetime-client/src/active/runtime.rs @@ -422,6 +422,34 @@ impl SpacetimeClient { .await } + /// 只读 AGC 模型权限:不刷新账期 / 免费点,供 LLM 代理热路径使用。 + pub async fn get_profile_agc_model_access( + &self, + user_id: String, + ) -> Result { + let procedure_input = build_runtime_profile_membership_get_input(user_id) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect( + "get_profile_agc_model_access_and_return", + move |connection, sender| { + connection + .procedures() + .get_profile_agc_model_access_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_agc_model_access_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn create_profile_recharge_order( &self, user_id: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index d7ef52226..55a56de2b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -488,6 +488,7 @@ pub mod get_game_distribution_my_review_and_return_procedure; pub mod get_game_distribution_version_and_return_procedure; pub mod get_llm_router_account_and_return_procedure; pub mod get_owner_game_distribution_version_and_return_procedure; +pub mod get_profile_agc_model_access_and_return_procedure; pub mod get_payment_order_and_return_procedure; pub mod get_payment_order_by_checkout_token_and_return_procedure; pub mod get_profile_dashboard_procedure; @@ -691,6 +692,8 @@ pub mod runtime_profile_admin_wallet_detail_procedure_result_type; pub mod runtime_profile_admin_wallet_get_input_type; pub mod runtime_profile_admin_wallet_procedure_result_type; pub mod runtime_profile_admin_wallet_snapshot_type; +pub mod runtime_profile_agc_model_access_procedure_result_type; +pub mod runtime_profile_agc_model_access_snapshot_type; pub mod runtime_profile_code_operation_snapshot_type; pub mod runtime_profile_daily_free_points_snapshot_type; pub mod runtime_profile_dashboard_get_input_type; @@ -1366,6 +1369,7 @@ pub use get_game_distribution_my_review_and_return_procedure::get_game_distribut pub use get_game_distribution_version_and_return_procedure::get_game_distribution_version_and_return; pub use get_llm_router_account_and_return_procedure::get_llm_router_account_and_return; pub use get_owner_game_distribution_version_and_return_procedure::get_owner_game_distribution_version_and_return; +pub use get_profile_agc_model_access_and_return_procedure::get_profile_agc_model_access_and_return; pub use get_payment_order_and_return_procedure::get_payment_order_and_return; pub use get_payment_order_by_checkout_token_and_return_procedure::get_payment_order_by_checkout_token_and_return; pub use get_profile_dashboard_procedure::get_profile_dashboard; @@ -1569,6 +1573,8 @@ pub use runtime_profile_admin_wallet_detail_procedure_result_type::RuntimeProfil pub use runtime_profile_admin_wallet_get_input_type::RuntimeProfileAdminWalletGetInput; pub use runtime_profile_admin_wallet_procedure_result_type::RuntimeProfileAdminWalletProcedureResult; pub use runtime_profile_admin_wallet_snapshot_type::RuntimeProfileAdminWalletSnapshot; +pub use runtime_profile_agc_model_access_procedure_result_type::RuntimeProfileAgcModelAccessProcedureResult; +pub use runtime_profile_agc_model_access_snapshot_type::RuntimeProfileAgcModelAccessSnapshot; pub use runtime_profile_code_operation_snapshot_type::RuntimeProfileCodeOperationSnapshot; pub use runtime_profile_daily_free_points_snapshot_type::RuntimeProfileDailyFreePointsSnapshot; pub use runtime_profile_dashboard_get_input_type::RuntimeProfileDashboardGetInput; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_agc_model_access_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_agc_model_access_and_return_procedure.rs new file mode 100644 index 000000000..eca693a53 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_agc_model_access_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_agc_model_access_procedure_result_type::RuntimeProfileAgcModelAccessProcedureResult; +use super::runtime_profile_membership_get_input_type::RuntimeProfileMembershipGetInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetProfileAgcModelAccessAndReturnArgs { + pub input: RuntimeProfileMembershipGetInput, +} + +impl __sdk::InModule for GetProfileAgcModelAccessAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_profile_agc_model_access_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_profile_agc_model_access_and_return { + fn get_profile_agc_model_access_and_return(&self, input: RuntimeProfileMembershipGetInput) { + self.get_profile_agc_model_access_and_return_then(input, |_, _| {}); + } + + fn get_profile_agc_model_access_and_return_then( + &self, + input: RuntimeProfileMembershipGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_profile_agc_model_access_and_return for super::RemoteProcedures { + fn get_profile_agc_model_access_and_return_then( + &self, + input: RuntimeProfileMembershipGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileAgcModelAccessProcedureResult>( + "get_profile_agc_model_access_and_return", + GetProfileAgcModelAccessAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_procedure_result_type.rs new file mode 100644 index 000000000..f95d34ce8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_agc_model_access_snapshot_type::RuntimeProfileAgcModelAccessSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileAgcModelAccessProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileAgcModelAccessProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_snapshot_type.rs new file mode 100644 index 000000000..ecbf88e77 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_snapshot_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_membership_model_access_type::RuntimeProfileMembershipModelAccess; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileAgcModelAccessSnapshot { + pub user_id: String, + pub plan: RuntimeProfileMembershipPlan, + pub model_access: RuntimeProfileMembershipModelAccess, +} + +impl __sdk::InModule for RuntimeProfileAgcModelAccessSnapshot { + type Module = super::RemoteModule; +} 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 7e1a9e4db..94fa85e50 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -1727,6 +1727,55 @@ pub fn get_profile_membership_snapshot_and_return( } } +/// 账号当前有效的 AGC 模型权限:只读账期投影 + 档位目录 `model_access`,不刷新、不写库。 +/// +/// 供 api-server 的 LLM 代理热路径使用;缺会员行 / 已过期按 `Normal`,缺目录行失败关闭到 `Basic`。 +#[spacetimedb::procedure] +pub fn get_profile_agc_model_access_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileMembershipGetInput, +) -> RuntimeProfileAgcModelAccessProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + let validated = build_runtime_profile_membership_get_input(input.user_id.clone()) + .map_err(|error| error.to_string())?; + Ok(build_profile_agc_model_access_snapshot_readonly( + tx, + &validated.user_id, + )) + }) { + Ok(record) => RuntimeProfileAgcModelAccessProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileAgcModelAccessProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +/// 纯读解析:用只读账期投影拿当前档位,再取档位目录的 `model_access`;缺目录行失败关闭到 `Basic`。 +fn build_profile_agc_model_access_snapshot_readonly( + ctx: &ReducerContext, + user_id: &str, +) -> RuntimeProfileAgcModelAccessSnapshot { + let membership = build_profile_membership_snapshot_readonly(ctx, user_id); + let model_access = membership_plan_row(ctx, membership.plan) + .map(|row| row.model_access) + .unwrap_or(RuntimeProfileMembershipModelAccess::Basic); + RuntimeProfileAgcModelAccessSnapshot { + user_id: membership.user_id, + plan: membership.plan, + model_access, + } +} + #[spacetimedb::procedure] pub fn get_profile_recharge_order_and_return( ctx: &mut ProcedureContext, From 243ad3c0e4b0e03cc92a2d0eb1da286e27a3e6ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 17:50:48 +0800 Subject: [PATCH 143/232] =?UTF-8?q?perf(=E4=BC=9A=E5=91=98):=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=9D=83=E9=99=90=E8=A7=A3=E6=9E=90=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=20procedure=EF=BC=8C=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E7=83=AD=E8=B7=AF=E5=BE=84=E5=86=99=E5=BA=93=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api-server llm/model_access.rs 改调 get_profile_agc_model_access,不再走带账期刷新的 get_profile_recharge_center - 失败关闭语义不变:procedure 失败或档位目录缺行仍按 Basic --- server-rs/crates/api-server/src/llm/model_access.rs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/server-rs/crates/api-server/src/llm/model_access.rs b/server-rs/crates/api-server/src/llm/model_access.rs index 65716b042..a6c094f03 100644 --- a/server-rs/crates/api-server/src/llm/model_access.rs +++ b/server-rs/crates/api-server/src/llm/model_access.rs @@ -17,8 +17,8 @@ pub(crate) struct OwnerAgcModelCatalog { /// 解析账号的 AGC 模型权限档。 /// -/// 走现有 `get_profile_recharge_center` 读模型(它同时返回会员行与档位目录行); -/// 该读带幂等的账期刷新写入,后续可替换为更轻的专用读。 +/// 走专用只读 procedure `get_profile_agc_model_access`:它只读会员账期投影与档位目录, +/// 不触发换期 / 免费点刷新,适合 LLM 代理热路径。 pub(crate) async fn resolve_owner_agc_model_access( state: &AppState, owner_user_id: &str, @@ -34,15 +34,10 @@ pub(crate) async fn resolve_owner_agc_model_access( } match state .spacetime_client() - .get_profile_recharge_center(owner_user_id.to_string()) + .get_profile_agc_model_access(owner_user_id.to_string()) .await { - Ok(center) => center - .membership_plans - .iter() - .find(|row| row.plan == center.membership.plan) - .map(|row| AgcModelAccess::from(row.model_access)) - .unwrap_or(AgcModelAccess::Basic), + Ok(record) => AgcModelAccess::from(record.model_access), Err(error) => { tracing::warn!( user_id = %owner_user_id, From 35a54d19e21b22905ceb5861f2f40596144bf03b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 17:52:10 +0800 Subject: [PATCH 144/232] =?UTF-8?q?docs(=E4=BC=9A=E5=91=98):=20=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=A8=A1=E5=9E=8B=E6=9D=83=E9=99=90=E6=94=B9=E8=B5=B0?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=20procedure=20=E7=9A=84=E8=90=BD=E5=9C=B0?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 技术设计 §11.4:api-server 改调 get_profile_agc_model_access_and_return,性能说明去掉 TODO 并登记提交 - decision-log 追加「模型权限改走只读 procedure(性能收口)」条目 --- docs/project-memory/shared-memory/decision-log.md | 7 +++++++ .../【技术设计】泥点三池与会员计费后端设计-2026-10-02.md | 6 +++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ab10fcd52..385c81cd4 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9652,6 +9652,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响范围:`docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md`(修订节)、技术设计 §11.2/§11.4、`docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`、`docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md`、本文件。 - 验证:`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 +## 2026-10-03 模型权限改走只读 procedure(性能收口) + +- 背景:`api-server` 的 AGC 模型权限解析原复用 `get_profile_recharge_center`,它带幂等的账期 / 免费点刷新与钱包 / 商品等无关快照字段,位于每个 `/api/llm/*` 请求热路径;同日「落地实现」条目已把它列为 TODO。 +- 决策:新增只读 procedure `get_profile_agc_model_access_and_return`(输入沿用 `RuntimeProfileMembershipGetInput`,结果 `RuntimeProfileAgcModelAccessSnapshot { user_id, plan, model_access }`,仅 editor generation runtime service identity 可调):只读会员账期投影 + `profile_membership_plan.model_access`,缺会员行 / 已过期按 `Normal`,缺档位目录行失败关闭到 `Basic`,不刷新、不写库;`api-server` 改调它,失败关闭语义不变。 +- 影响范围:`module-runtime`(领域类型 `9e397fe9c`)、`spacetime-module`(procedure,同提交)、生成绑定、`spacetime-client`(`get_profile_agc_model_access`)、`api-server/src/llm/model_access.rs`(`b0dc94b46`)、技术设计 §11.4。 +- 验证:`cargo check -p spacetime-module`、`cargo check -p spacetime-client`、`cargo check -p api-server --tests`;`cargo test -p api-server llm` 51 passed;`npm run check:encoding`、`git diff --check`;`npm run check:spacetime-schema` 失败项仍为分支既有 `profile_membership` / `profile_recharge_product_config` 表字段变更,与本次仅新增 procedure、未改表无关。 + ## 2026-10-03 会员链路错误分类改为机器可读错误码(typed error) - 背景:`api-server/src/runtime_profile.rs` 的 `is_runtime_profile_membership_domain_error` 用中文 `error_message` 前缀 / 精确匹配决定返回 400 还是 502;文案一改(或新增拒绝点)就静默退化,且分类逻辑与 module 的错误文案跨层重复。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index d6826571b..aae4bbf1a 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -606,13 +606,13 @@ flowchart TD - `AgcModel` 末位追加 `#[serde(default)] access: AgcModelAccess`(存量目录 JSON 缺字段即 `Basic`,失败开放);`AgcModelCatalog` 新增 `available_models_for(access)`、`default_model_id_for(access)`、`resolve_requested_for(requested, access)`。 - **默认模型语义**:优先取目录登记的 `default_model_id`;当它不在该档可用集合里时,回退到目录顺序里第一个可用项;该档一个可用模型都没有时返回 `Unavailable`。 - **`spacetime-module`**:`external_generation_job` 追加 btree 索引 `(owner_user_id, status)`(`by_external_generation_job_owner_status`);`claim_external_generation_jobs_tx` 按账号解析 `concurrent_job_limit` 并在认领时过滤;上限解析走 `profile_membership.plan` → `profile_membership_plan.concurrent_job_limit`,缺会员行 / 缺目录行失败关闭到 `Normal`(=1)。 -- **`api-server`**:新增 `llm/model_access.rs`,用现有 `get_profile_recharge_center` 读出账号 `membership.plan` 与目录行 `model_access` 并映射到 `AgcModelAccess`;`GET /api/llm/models` 按档过滤,`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全部按档解析,越权返回 403 + 错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`。 +- **`api-server`**:新增 `llm/model_access.rs`,调专用只读 procedure `get_profile_agc_model_access_and_return`(只读会员账期投影 + 档位目录 `model_access`,不刷新、不写库)并映射到 `AgcModelAccess`;`GET /api/llm/models` 按档过滤,`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全部按档解析,越权返回 403 + 错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`。 - **后台**:`AdminAgcModel` 与后台「AGC 模型」页新增 `access` 字段(`basic` / `full`,缺省 `basic`),GET 回读、PUT 写入;**不做**自动播种或代码内清单。 -- **性能说明**:本期账号档位复用现有 `get_profile_recharge_center` 读模型,它带幂等的周期刷新写入;后续可替换为更轻的专用读(TODO)。 +- **性能说明(2026-10-03 已落地)**:账号档位走专用只读 procedure `get_profile_agc_model_access_and_return`(`9e397fe9c`),不再触发 `get_profile_recharge_center` 的账期 / 免费点刷新;`api-server` 于 `b0dc94b46` 切到该读。认领按账号折叠并发查询见 `c3c3fc5a2`。缺会员行 / 已过期按 `Normal`,缺档位目录行失败关闭到 `Basic`。 - **列表契约(2026-10-03 修订)**:`shared-contracts` 新增 `llm_catalog.rs` 承载 `LlmModelsResponse` / `LlmModelSummary` / `LlmUnavailableModel` / `LlmModelUnavailableReason`(`plan_required` / `disabled` / `unknown`);`AgcAgentMode` / `AgcModelProtocol` 从 `module-runtime` 迁入 `shared-contracts` 并由 `module-runtime` re-export;整个目录 DTO 与枚举用 `#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]` 导出到 `packages/shared/src/contracts/generated/`,`revision` 标 `#[ts(as = "f64")]`,AGC 前端从 `@genarrative/shared` 根导入,删掉手写的 `ClientLlmModel`。 - **分桶来源**:`module-runtime` 新增 `AgcModelCatalog::unavailable_models_for(access)`(保持目录顺序,返回 `(&AgcModel, LlmModelUnavailableReason)`),`api-server` 只做 DTO 映射;`models` 仍走 `available_models_for`;`unavailableModels` 恒定下发(无内容时为空数组)。 - **客户端**:AGC 桌面选择器把 `unavailableModels` 渲染为不可点项,hover / 键盘 focus 出 tooltip(`plan_required` → 「订阅计划不支持」、`disabled` → 「该模型已下线」、`unknown` → 「暂不可用」);降级仍自动回退默认,提示按 `reason` 取;后台直接删除(两桶都没有)用泛化文案「所选模型已不可用,已切回默认模型」。 -- **落地状态(2026-10-03)**:`module-runtime` 权限档与目录解析 `da9602839`;`spacetime-module` 并发索引与认领过滤 `df7682816`;后台 `access` 标注与页面 `54b02e228`;`api-server` 按档过滤 / 校验 `3aae77bc5`;认领按账号折叠并发查询 `c3c3fc5a2`。AGC 客户端侧原以「目录按档过滤 + 本地选择对账回退默认项」为主,本次修订为可用 / 不可用分桶。 +- **落地状态(2026-10-03)**:`module-runtime` 权限档与目录解析 `da9602839`;`spacetime-module` 并发索引与认领过滤 `df7682816`;后台 `access` 标注与页面 `54b02e228`;`api-server` 按档过滤 / 校验 `3aae77bc5`;认领按账号折叠并发查询 `c3c3fc5a2`。AGC 客户端侧原以「目录按档过滤 + 本地选择对账回退默认项」为主,本次修订为可用 / 不可用分桶。账号 AGC 模型权限改走只读 procedure `9e397fe9c`,`api-server` 切读 `b0dc94b46`。 --- From e89420053ac16aed927cab21bb97e6aea3a29fa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 18:03:20 +0800 Subject: [PATCH 145/232] =?UTF-8?q?feat(=E4=BC=9A=E5=91=98):=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=88=97=E8=A1=A8=E8=BF=94=E5=9B=9E=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E4=B8=8E=E4=B8=8D=E5=8F=AF=E7=94=A8=E5=88=86=E6=A1=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared-contracts 新增 llm_catalog.rs:LlmModelsResponse/LlmModelSummary/LlmUnavailableModel/LlmModelUnavailableReason(plan_required/disabled/unknown),未停用优先于档位的 disabled 语义留给域层 - AgcAgentMode/AgcModelProtocol 从 module-runtime 迁入 shared-contracts(前后端 + ts-rs 单一真源),module-runtime re-export 保持路径可用 - 整个目录 DTO 与枚举走 ts-rs,生成到 packages/shared/src/contracts/generated,revision 标 number - module-runtime 新增 AgcModelCatalog::unavailable_models_for(access):保持目录顺序,停用优先于档位不足,补单测 - api-server public_model_catalog 产出 models + unavailableModels 两桶,重写 model_catalog_tests --- .../src/contracts/generated/AgcAgentMode.ts | 6 + .../contracts/generated/AgcModelProtocol.ts | 10 + .../contracts/generated/LlmModelSummary.ts | 16 ++ .../generated/LlmModelUnavailableReason.ts | 8 + .../contracts/generated/LlmModelsResponse.ts | 20 ++ .../generated/LlmUnavailableModel.ts | 9 + server-rs/crates/api-server/src/llm/mod.rs | 43 +++- .../module-runtime/src/agc_model_access.rs | 57 +++++ .../crates/module-runtime/src/agc_models.rs | 96 +------- server-rs/crates/shared-contracts/src/lib.rs | 1 + server-rs/crates/shared-contracts/src/llm.rs | 25 +- .../shared-contracts/src/llm_catalog.rs | 226 ++++++++++++++++++ 12 files changed, 393 insertions(+), 124 deletions(-) create mode 100644 packages/shared/src/contracts/generated/AgcAgentMode.ts create mode 100644 packages/shared/src/contracts/generated/AgcModelProtocol.ts create mode 100644 packages/shared/src/contracts/generated/LlmModelSummary.ts create mode 100644 packages/shared/src/contracts/generated/LlmModelUnavailableReason.ts create mode 100644 packages/shared/src/contracts/generated/LlmModelsResponse.ts create mode 100644 packages/shared/src/contracts/generated/LlmUnavailableModel.ts create mode 100644 server-rs/crates/shared-contracts/src/llm_catalog.rs diff --git a/packages/shared/src/contracts/generated/AgcAgentMode.ts b/packages/shared/src/contracts/generated/AgcAgentMode.ts new file mode 100644 index 000000000..a23cf0075 --- /dev/null +++ b/packages/shared/src/contracts/generated/AgcAgentMode.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 客户端启动的执行器。与线协议正交。 + */ +export type AgcAgentMode = "codex" | "cc"; diff --git a/packages/shared/src/contracts/generated/AgcModelProtocol.ts b/packages/shared/src/contracts/generated/AgcModelProtocol.ts new file mode 100644 index 000000000..c6fa1e618 --- /dev/null +++ b/packages/shared/src/contracts/generated/AgcModelProtocol.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 上游线协议,与执行器正交。 + * + * `openai_responses` / `openai_chat` 走 `/api/llm/v1/responses` 与 + * `/api/llm/v1/chat/completions`;`anthropic` 走独立的 + * `/api/llm/anthropic/{*path}`。 + */ +export type AgcModelProtocol = "openai_responses" | "openai_chat" | "anthropic"; diff --git a/packages/shared/src/contracts/generated/LlmModelSummary.ts b/packages/shared/src/contracts/generated/LlmModelSummary.ts new file mode 100644 index 000000000..be2e3c30c --- /dev/null +++ b/packages/shared/src/contracts/generated/LlmModelSummary.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgcAgentMode } from "./AgcAgentMode"; +import type { AgcModelProtocol } from "./AgcModelProtocol"; + +/** + * 本档可用的一条模型摘要。不包含上游真实模型名。 + */ +export type LlmModelSummary = { displayName: string, id: string, +/** + * 执行器;旧服务端缺字段时按 `codex` 兼容。 + */ +agentMode: AgcAgentMode, +/** + * 上游线协议;旧服务端缺字段时按 `openai_responses` 兼容。 + */ +protocol: AgcModelProtocol, }; diff --git a/packages/shared/src/contracts/generated/LlmModelUnavailableReason.ts b/packages/shared/src/contracts/generated/LlmModelUnavailableReason.ts new file mode 100644 index 000000000..a9a55039a --- /dev/null +++ b/packages/shared/src/contracts/generated/LlmModelUnavailableReason.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 模型对当前账号不可用的原因。 + * + * `Unknown` 是前向兜底:后端将来新增取值时旧客户端不会解析失败。 + */ +export type LlmModelUnavailableReason = "plan_required" | "disabled" | "unknown"; diff --git a/packages/shared/src/contracts/generated/LlmModelsResponse.ts b/packages/shared/src/contracts/generated/LlmModelsResponse.ts new file mode 100644 index 000000000..ce1a75fef --- /dev/null +++ b/packages/shared/src/contracts/generated/LlmModelsResponse.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LlmModelSummary } from "./LlmModelSummary"; +import type { LlmUnavailableModel } from "./LlmUnavailableModel"; + +/** + * `GET /api/llm/models` 的响应。 + */ +export type LlmModelsResponse = { defaultModelId: string, +/** + * 本档可用,保持目录顺序。 + */ +models: Array, +/** + * 目录里其余全部,保持目录顺序;旧服务端缺字段时为空。 + */ +unavailableModels: Array, +/** + * 模型目录版本;ts-rs 默认把 `u64` 映射成 `bigint`,前端消费 `number`。 + */ +revision: number, }; diff --git a/packages/shared/src/contracts/generated/LlmUnavailableModel.ts b/packages/shared/src/contracts/generated/LlmUnavailableModel.ts new file mode 100644 index 000000000..f41401bbc --- /dev/null +++ b/packages/shared/src/contracts/generated/LlmUnavailableModel.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgcAgentMode } from "./AgcAgentMode"; +import type { AgcModelProtocol } from "./AgcModelProtocol"; +import type { LlmModelUnavailableReason } from "./LlmModelUnavailableReason"; + +/** + * 目录里存在、但当前账号不可用的一条模型。字段与 [`LlmModelSummary`] 对齐,另带原因。 + */ +export type LlmUnavailableModel = { displayName: string, id: string, agentMode: AgcAgentMode, protocol: AgcModelProtocol, reason: LlmModelUnavailableReason, }; diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 385dd21cf..f25a64ab5 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -14,7 +14,7 @@ use platform_llm::{LlmApiKind, LlmMessage, LlmMessageRole, LlmRunRequest}; use serde_json::{Value, json}; use shared_contracts::llm::{ LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole, - LlmModelSummary, LlmModelsResponse, + LlmModelSummary, LlmModelsResponse, LlmUnavailableModel, }; use spacetime_client::SpacetimeClientError; use std::convert::Infallible; @@ -52,7 +52,7 @@ mod model_catalog_tests { use super::*; #[test] - fn public_catalog_exposes_stable_id_and_upstream_alias() { + fn public_catalog_splits_available_and_disabled_models() { let mut catalog = module_runtime::AgcModelCatalog::from_upstream_models( vec!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()], 7, @@ -72,15 +72,21 @@ mod model_catalog_tests { payload["models"], json!([{"id": "gpt-5-6-sol", "displayName": "gpt-5.6-sol", "agentMode": "codex", "protocol": "openai_responses"}]) ); + // 后台停用的模型仍下发到不可用桶,带 disabled 原因。 + assert_eq!( + payload["unavailableModels"], + json!([{"id": "gpt-5-6-terra", "displayName": "gpt-5.6-terra", "agentMode": "codex", "protocol": "openai_responses", "reason": "disabled"}]) + ); assert_eq!(payload["defaultModelId"], "gpt-5-6-sol"); assert_eq!(payload["revision"], json!(7)); assert!(payload.get("defaultModel").is_none()); assert!(payload["models"][0].get("enabled").is_none()); assert!(payload["models"][0].get("modelId").is_none()); + assert!(payload["unavailableModels"][0].get("modelId").is_none()); } #[test] - fn public_catalog_filters_full_models_for_basic_plan() { + fn public_catalog_puts_full_models_in_plan_required_bucket_for_basic_plan() { let mut catalog = module_runtime::AgcModelCatalog::from_upstream_models( vec!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()], 0, @@ -100,6 +106,10 @@ mod model_catalog_tests { payload["models"], json!([{"id": "gpt-5-6-terra", "displayName": "gpt-5.6-terra", "agentMode": "codex", "protocol": "openai_responses"}]) ); + assert_eq!( + payload["unavailableModels"], + json!([{"id": "gpt-5-6-sol", "displayName": "gpt-5.6-sol", "agentMode": "codex", "protocol": "openai_responses", "reason": "plan_required"}]) + ); assert_eq!(payload["defaultModelId"], "gpt-5-6-terra"); } @@ -268,20 +278,29 @@ fn public_model_catalog( .to_string(); let models = catalog .available_models_for(access) - .map(|model| { - let protocol = model.resolved_protocol().as_str().to_string(); - LlmModelSummary { - id: model.id.clone(), - // 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。 - display_name: model.alias.clone(), - agent_mode: model.agent_mode.as_str().to_string(), - protocol, - } + .map(|model| LlmModelSummary { + id: model.id.clone(), + // 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。 + display_name: model.alias.clone(), + agent_mode: model.agent_mode, + protocol: model.resolved_protocol(), + }) + .collect(); + // 不可用桶只是展示信息:含后台停用与本档档位不够,保持目录顺序。 + let unavailable_models = catalog + .unavailable_models_for(access) + .map(|(model, reason)| LlmUnavailableModel { + id: model.id.clone(), + display_name: model.alias.clone(), + agent_mode: model.agent_mode, + protocol: model.resolved_protocol(), + reason, }) .collect(); Ok(LlmModelsResponse { default_model_id, models, + unavailable_models, revision: catalog.revision, }) } diff --git a/server-rs/crates/module-runtime/src/agc_model_access.rs b/server-rs/crates/module-runtime/src/agc_model_access.rs index 76559aaed..051475330 100644 --- a/server-rs/crates/module-runtime/src/agc_model_access.rs +++ b/server-rs/crates/module-runtime/src/agc_model_access.rs @@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize}; use crate::{ AGC_MODEL_PLATFORM_DEFAULT, AgcModel, AgcModelCatalog, RuntimeProfileMembershipModelAccess, }; +use shared_contracts::llm_catalog::LlmModelUnavailableReason; /// 越权选择模型时对外返回的专用错误码(`api-server` 直接透传到 `AppError.code`)。 pub const MODEL_NOT_AVAILABLE_FOR_PLAN: &str = "MODEL_NOT_AVAILABLE_FOR_PLAN"; @@ -105,6 +106,26 @@ impl AgcModelCatalog { .filter(move |model| model.is_available_for(access)) } + /// 该档位**不可用**的模型(含后台停用与本档档位不够),保持目录顺序。 + /// + /// 与 [`Self::available_models_for`] 互补:两桶并集是目录全集、互不相交。同时停用且 + /// 档位不够时取 [`LlmModelUnavailableReason::Disabled`]——升级也解锁不了,不能标 + /// `plan_required` 误导用户。 + pub fn unavailable_models_for( + &self, + access: AgcModelAccess, + ) -> impl Iterator { + self.models.iter().filter_map(move |model| { + if !model.enabled { + Some((model, LlmModelUnavailableReason::Disabled)) + } else if !access.allows(model.access) { + Some((model, LlmModelUnavailableReason::PlanRequired)) + } else { + None + } + }) + } + /// 该档位的默认模型标识:优先目录登记的 `default_model_id`,不在该档可用集合里时 /// 回退到目录顺序里第一个可用项;一个可用模型都没有时返回 `None`。 pub fn default_model_id_for(&self, access: AgcModelAccess) -> Option<&str> { @@ -287,4 +308,40 @@ mod tests { "MODEL_UNAVAILABLE" ); } + + #[test] + fn unavailable_models_for_splits_disabled_and_plan_gated_in_catalog_order() { + let mut catalog = catalog_from_ids(&["model-a", "model-b", "model-c"], 0); + mark_full(&mut catalog, "model-b"); + mark_full(&mut catalog, "model-c"); + catalog.models[2].enabled = false; + + let unavailable = catalog + .unavailable_models_for(AgcModelAccess::Basic) + .map(|(model, reason)| (model.id.as_str(), reason)) + .collect::>(); + // model-c 同时停用且 Full:reason 取 disabled(升级也解锁不了)。 + assert_eq!( + unavailable, + vec![ + ("model-b", LlmModelUnavailableReason::PlanRequired), + ("model-c", LlmModelUnavailableReason::Disabled), + ] + ); + + // Full 档只剩停用的 model-c;可用集合与不可用集合互补。 + let unavailable_full = catalog + .unavailable_models_for(AgcModelAccess::Full) + .map(|(model, reason)| (model.id.as_str(), reason)) + .collect::>(); + assert_eq!( + unavailable_full, + vec![("model-c", LlmModelUnavailableReason::Disabled)] + ); + let available = catalog + .available_models_for(AgcModelAccess::Basic) + .map(|model| model.id.as_str()) + .collect::>(); + assert_eq!(available, vec!["model-a"]); + } } diff --git a/server-rs/crates/module-runtime/src/agc_models.rs b/server-rs/crates/module-runtime/src/agc_models.rs index 05e115db8..31479f3d8 100644 --- a/server-rs/crates/module-runtime/src/agc_models.rs +++ b/server-rs/crates/module-runtime/src/agc_models.rs @@ -14,99 +14,9 @@ pub const AGC_MODEL_ID_MAX_BYTES: usize = 64; /// 目录项数上限,与后台「AGC 模型」页的新增上限保持一致。 pub const AGC_MODEL_CATALOG_MAX_MODELS: usize = 32; -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum AgcAgentMode { - Codex, - /// `anthropic` 是早期把协议混进执行器时写下的历史取值,读取时归一到 `cc`; - /// 协议本身由 `AgcModel.protocol` 表达。 - #[serde(alias = "anthropic")] - Cc, -} - -impl Default for AgcAgentMode { - fn default() -> Self { - Self::Codex - } -} - -impl AgcAgentMode { - pub const fn as_str(self) -> &'static str { - match self { - Self::Codex => "codex", - Self::Cc => "cc", - } - } - - pub fn parse(value: &str) -> Result { - match value { - "codex" => Ok(Self::Codex), - "cc" => Ok(Self::Cc), - _ => Err("Agent 模式只允许 codex 或 cc".into()), - } - } - - /// 该执行器允许的线协议。协议与执行器是正交维度,但合法组合是封闭的: - /// Codex 只能走 OpenAI Responses / Chat Completions,Claude Code 只能走 - /// Anthropic Messages。 - pub const fn supports(self, protocol: AgcModelProtocol) -> bool { - match self { - Self::Codex => matches!( - protocol, - AgcModelProtocol::OpenAiResponses | AgcModelProtocol::OpenAiChat - ), - Self::Cc => matches!(protocol, AgcModelProtocol::Anthropic), - } - } - - /// 存量目录缺少协议字段时按执行器的默认线协议解释。 - pub const fn default_protocol(self) -> AgcModelProtocol { - match self { - Self::Codex => AgcModelProtocol::OpenAiResponses, - Self::Cc => AgcModelProtocol::Anthropic, - } - } -} - -/// 上游线协议,与执行器正交。 -/// -/// `openai_responses` / `openai_chat` 走 `/api/llm/v1/responses` 与 -/// `/api/llm/v1/chat/completions`;`anthropic` 走独立的 -/// `/api/llm/anthropic/{*path}`。 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum AgcModelProtocol { - #[serde(rename = "openai_responses")] - OpenAiResponses, - #[serde(rename = "openai_chat")] - OpenAiChat, - #[serde(rename = "anthropic")] - Anthropic, -} - -impl Default for AgcModelProtocol { - fn default() -> Self { - Self::OpenAiResponses - } -} - -impl AgcModelProtocol { - pub const fn as_str(self) -> &'static str { - match self { - Self::OpenAiResponses => "openai_responses", - Self::OpenAiChat => "openai_chat", - Self::Anthropic => "anthropic", - } - } - - pub fn parse(value: &str) -> Result { - match value { - "openai_responses" => Ok(Self::OpenAiResponses), - "openai_chat" => Ok(Self::OpenAiChat), - "anthropic" => Ok(Self::Anthropic), - _ => Err("协议只允许 openai_responses、openai_chat 或 anthropic".into()), - } - } -} +// 执行器与线协议枚举已迁到 `shared-contracts`(前后端 + ts-rs 单一真源); +// 这里 re-export,保持 `module_runtime::AgcAgentMode` / `AgcModelProtocol` 路径可用。 +pub use shared_contracts::llm_catalog::{AgcAgentMode, AgcModelProtocol}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index ed92100fb..08ce1bec6 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -23,5 +23,6 @@ pub mod game_creation_app; pub mod game_distribution; pub mod hyper3d; pub mod llm; +pub mod llm_catalog; pub mod payment; pub mod runtime; diff --git a/server-rs/crates/shared-contracts/src/llm.rs b/server-rs/crates/shared-contracts/src/llm.rs index a02708cf7..a729f0994 100644 --- a/server-rs/crates/shared-contracts/src/llm.rs +++ b/server-rs/crates/shared-contracts/src/llm.rs @@ -60,22 +60,9 @@ mod tests { assert_eq!(payload["messages"][0]["role"], json!("system")); } } -#[derive(Debug, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LlmModelSummary { - pub display_name: String, - pub id: String, - /// 执行器:`codex` 或 `cc`。 - pub agent_mode: String, - /// 上游线协议,与执行器正交:`openai_responses` / `openai_chat` / `anthropic`。 - pub protocol: String, -} - -#[derive(Debug, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LlmModelsResponse { - pub default_model_id: String, - pub models: Vec, - /// 模型目录版本,客户端据此做条件刷新。 - pub revision: u64, -} +// 目录 DTO 与枚举已迁到 `llm_catalog`(同一 ts-rs 生成目录);这里 re-export 保持既有 +// `shared_contracts::llm::*` 路径可用。 +pub use crate::llm_catalog::{ + AgcAgentMode, AgcModelProtocol, LlmModelSummary, LlmModelUnavailableReason, LlmModelsResponse, + LlmUnavailableModel, +}; diff --git a/server-rs/crates/shared-contracts/src/llm_catalog.rs b/server-rs/crates/shared-contracts/src/llm_catalog.rs new file mode 100644 index 000000000..72a79bfce --- /dev/null +++ b/server-rs/crates/shared-contracts/src/llm_catalog.rs @@ -0,0 +1,226 @@ +//! AGC LLM 模型目录的对外契约与枚举(前后端唯一真源)。 +//! +//! `GET /api/llm/models` 返回两个平行数组:`models` 只放本档可用模型; +//! `unavailableModels` 放目录里其余全部(含后台停用与本档档位不够),元素带 `reason`。 +//! 整个 DTO 与枚举经 ts-rs 导出到 `packages/shared/src/contracts/generated/`, +//! AGC 前端不再手写第二份类型。 + +use serde::{Deserialize, Serialize}; + +/// 客户端启动的执行器。与线协议正交。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub enum AgcAgentMode { + Codex, + /// `anthropic` 是早期把协议混进执行器时写下的历史取值,读取时归一到 `cc`; + /// 协议本身由 [`AgcModelProtocol`] 表达。 + #[serde(alias = "anthropic")] + Cc, +} + +impl Default for AgcAgentMode { + fn default() -> Self { + Self::Codex + } +} + +impl AgcAgentMode { + pub const fn as_str(self) -> &'static str { + match self { + Self::Codex => "codex", + Self::Cc => "cc", + } + } + + pub fn parse(value: &str) -> Result { + match value { + "codex" => Ok(Self::Codex), + "cc" => Ok(Self::Cc), + _ => Err("Agent 模式只允许 codex 或 cc".into()), + } + } + + /// 该执行器允许的线协议。协议与执行器是正交维度,但合法组合是封闭的: + /// Codex 只能走 OpenAI Responses / Chat Completions,Claude Code 只能走 + /// Anthropic Messages。 + pub const fn supports(self, protocol: AgcModelProtocol) -> bool { + match self { + Self::Codex => matches!( + protocol, + AgcModelProtocol::OpenAiResponses | AgcModelProtocol::OpenAiChat + ), + Self::Cc => matches!(protocol, AgcModelProtocol::Anthropic), + } + } + + /// 存量目录缺少协议字段时按执行器的默认线协议解释。 + pub const fn default_protocol(self) -> AgcModelProtocol { + match self { + Self::Codex => AgcModelProtocol::OpenAiResponses, + Self::Cc => AgcModelProtocol::Anthropic, + } + } +} + +/// 上游线协议,与执行器正交。 +/// +/// `openai_responses` / `openai_chat` 走 `/api/llm/v1/responses` 与 +/// `/api/llm/v1/chat/completions`;`anthropic` 走独立的 +/// `/api/llm/anthropic/{*path}`。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub enum AgcModelProtocol { + #[serde(rename = "openai_responses")] + OpenAiResponses, + #[serde(rename = "openai_chat")] + OpenAiChat, + #[serde(rename = "anthropic")] + Anthropic, +} + +impl Default for AgcModelProtocol { + fn default() -> Self { + Self::OpenAiResponses + } +} + +impl AgcModelProtocol { + pub const fn as_str(self) -> &'static str { + match self { + Self::OpenAiResponses => "openai_responses", + Self::OpenAiChat => "openai_chat", + Self::Anthropic => "anthropic", + } + } + + pub fn parse(value: &str) -> Result { + match value { + "openai_responses" => Ok(Self::OpenAiResponses), + "openai_chat" => Ok(Self::OpenAiChat), + "anthropic" => Ok(Self::Anthropic), + _ => Err("协议只允许 openai_responses、openai_chat 或 anthropic".into()), + } + } +} + +/// 本档可用的一条模型摘要。不包含上游真实模型名。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub struct LlmModelSummary { + pub display_name: String, + pub id: String, + /// 执行器;旧服务端缺字段时按 `codex` 兼容。 + #[serde(default)] + pub agent_mode: AgcAgentMode, + /// 上游线协议;旧服务端缺字段时按 `openai_responses` 兼容。 + #[serde(default)] + pub protocol: AgcModelProtocol, +} + +/// 目录里存在、但当前账号不可用的一条模型。字段与 [`LlmModelSummary`] 对齐,另带原因。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub struct LlmUnavailableModel { + pub display_name: String, + pub id: String, + #[serde(default)] + pub agent_mode: AgcAgentMode, + #[serde(default)] + pub protocol: AgcModelProtocol, + pub reason: LlmModelUnavailableReason, +} + +/// 模型对当前账号不可用的原因。 +/// +/// `Unknown` 是前向兜底:后端将来新增取值时旧客户端不会解析失败。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub enum LlmModelUnavailableReason { + /// 会员档位不够(升级可解锁)。 + PlanRequired, + /// 后台人工停用(升级也解锁不了)。 + Disabled, + /// 未知原因的前向兜底;当前服务端不会主动产出。 + #[serde(other)] + Unknown, +} + +/// `GET /api/llm/models` 的响应。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub struct LlmModelsResponse { + pub default_model_id: String, + /// 本档可用,保持目录顺序。 + pub models: Vec, + /// 目录里其余全部,保持目录顺序;旧服务端缺字段时为空。 + #[serde(default)] + pub unavailable_models: Vec, + /// 模型目录版本;ts-rs 默认把 `u64` 映射成 `bigint`,前端消费 `number`。 + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] + pub revision: u64, +} From f3f89efddd0fc05d21918554414509228c5b9179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 18:03:47 +0800 Subject: [PATCH 146/232] =?UTF-8?q?feat(shared):=20=E8=BD=AC=E5=8F=91=20AG?= =?UTF-8?q?C=20=E6=A8=A1=E5=9E=8B=E7=9B=AE=E5=BD=95=20ts-rs=20=E7=BB=91?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 packages/shared/src/llm/modelCatalog.ts,re-export Rust 生成的目录 DTO 与枚举 - 根 index.ts 增加 export type * from './llm/modelCatalog',AGC 从 @genarrative/shared 根导入 --- packages/shared/src/index.ts | 1 + packages/shared/src/llm/modelCatalog.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) create mode 100644 packages/shared/src/llm/modelCatalog.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 56504ea57..ecf7a17c8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -10,6 +10,7 @@ export type * from './contracts/hyper3d'; export * from './contracts/payment'; export * from './contracts/runtime'; export * from './http'; +export type * from './llm/modelCatalog'; export * from './llm/narrativeLanguage'; export * from './llm/parsers'; export * from './stores/createProfileWalletStore'; diff --git a/packages/shared/src/llm/modelCatalog.ts b/packages/shared/src/llm/modelCatalog.ts new file mode 100644 index 000000000..a483dd19a --- /dev/null +++ b/packages/shared/src/llm/modelCatalog.ts @@ -0,0 +1,12 @@ +/** + * AGC LLM 模型目录契约(Rust `shared-contracts::llm_catalog` 的 ts-rs 生成物)。 + * + * 前端不再手写第二份 `ClientLlmModel`:可用 / 不可用分桶、`reason` 枚举与 + * `agentMode` / `protocol` 联合类型都以这里为准。 + */ +export type { AgcAgentMode } from '../contracts/generated/AgcAgentMode'; +export type { AgcModelProtocol } from '../contracts/generated/AgcModelProtocol'; +export type { LlmModelsResponse } from '../contracts/generated/LlmModelsResponse'; +export type { LlmModelSummary } from '../contracts/generated/LlmModelSummary'; +export type { LlmModelUnavailableReason } from '../contracts/generated/LlmModelUnavailableReason'; +export type { LlmUnavailableModel } from '../contracts/generated/LlmUnavailableModel'; From 9437b71aee07c843752312ce9a5d1646164cf633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 18:03:58 +0800 Subject: [PATCH 147/232] =?UTF-8?q?feat(AGC):=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=20wire=20=E5=A4=8D=E7=94=A8=20shared-contrac?= =?UTF-8?q?ts=20DTO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - llm_catalog.rs 删掉手写 WireModelCatalog/WireModelSummary 与 map_catalog,直接反序列化 LlmModelsResponse - 旧服务端缺 agentMode/protocol/unavailableModels 时按 Codex + OpenAI Responses + 空桶兼容 - 新增未知 reason 收口为 unknown 且 agentMode/protocol 解析为枚举的单测 --- .../src-tauri/src/llm_catalog.rs | 97 +++++++++---------- 1 file changed, 47 insertions(+), 50 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs index 7e89cb64f..620701eb0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs @@ -6,40 +6,12 @@ use futures::StreamExt; use serde::Deserialize; use serde_json::Value; use shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER; -use shared_contracts::llm::{LlmModelSummary, LlmModelsResponse}; +use shared_contracts::llm::LlmModelsResponse; use std::time::Duration; const API_RESPONSE_ENVELOPE_VERSION: &str = "v1"; const MODEL_CATALOG_MAX_BYTES: usize = 1024 * 1024; -fn default_codex_agent_mode() -> String { - "codex".to_string() -} - -fn default_model_protocol() -> String { - "openai_responses".to_string() -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct WireModelSummary { - id: String, - display_name: String, - #[serde(default = "default_codex_agent_mode")] - agent_mode: String, - /// 线协议与执行器正交;旧服务端不下发时按 OpenAI Responses 兼容。 - #[serde(default = "default_model_protocol")] - protocol: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct WireModelCatalog { - default_model_id: String, - models: Vec, - revision: u64, -} - #[derive(Debug, Deserialize)] struct ApiErrorPayload { code: Option, @@ -49,27 +21,12 @@ struct ApiErrorPayload { #[derive(Debug, Deserialize)] struct ApiEnvelope { ok: bool, - data: Option, + /// 直接复用 shared-contracts 的目录 DTO:`agentMode` / `protocol` / `unavailableModels` + /// 都带 `#[serde(default)]`,旧服务端缺字段时按 Codex + OpenAI Responses + 空桶兼容。 + data: Option, error: Option, } -fn map_catalog(catalog: WireModelCatalog) -> LlmModelsResponse { - LlmModelsResponse { - default_model_id: catalog.default_model_id, - models: catalog - .models - .into_iter() - .map(|model| LlmModelSummary { - id: model.id, - display_name: model.display_name, - agent_mode: model.agent_mode, - protocol: model.protocol, - }) - .collect(), - revision: catalog.revision, - } -} - fn parse_catalog_payload(bytes: &[u8]) -> Result { let value: Value = serde_json::from_slice(bytes) .map_err(|_| "模型列表响应不是有效 JSON,请稍后重试".to_string())?; @@ -98,12 +55,10 @@ fn parse_catalog_payload(bytes: &[u8]) -> Result { } return envelope .data - .map(map_catalog) .ok_or_else(|| "模型列表响应缺少 data,请稍后重试".to_string()); } - serde_json::from_value::(value) - .map(map_catalog) + serde_json::from_value::(value) .map_err(|_| "模型列表响应格式无效,请稍后重试".to_string()) } @@ -293,6 +248,48 @@ mod tests { .contains("x-genarrative-response-envelope: v1")); } + #[test] + fn catalog_maps_unknown_unavailable_reason_to_unknown() { + let body = serde_json::json!({ + "ok": true, + "data": { + "defaultModelId": "quality", + "models": [{ + "id": "quality", + "displayName": "高质量", + "agentMode": "codex", + "protocol": "openai_responses" + }], + "unavailableModels": [{ + "id": "pro", + "displayName": "专业版", + "agentMode": "cc", + "protocol": "anthropic", + "reason": "some_future_reason" + }], + "revision": 8 + }, + "error": null + }) + .to_string(); + + let catalog = parse_catalog_payload(body.as_bytes()).expect("catalog should parse"); + assert_eq!(catalog.models.len(), 1); + assert_eq!(catalog.unavailable_models.len(), 1); + assert_eq!( + catalog.unavailable_models[0].reason, + shared_contracts::llm::LlmModelUnavailableReason::Unknown + ); + assert_eq!( + catalog.unavailable_models[0].agent_mode, + shared_contracts::llm::AgcAgentMode::Cc + ); + assert_eq!( + catalog.unavailable_models[0].protocol, + shared_contracts::llm::AgcModelProtocol::Anthropic + ); + } + #[tokio::test] async fn official_catalog_fails_closed_on_http_error_without_accepting_error_as_data() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture"); From 93fc9f98bac429dcf83af88aa3ed9979a2086ddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 18:04:11 +0800 Subject: [PATCH 148/232] =?UTF-8?q?feat(AGC):=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=99=A8=E5=B1=95=E7=A4=BA=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=A8=A1=E5=9E=8B=E4=B8=8E=E9=99=8D=E7=BA=A7=E5=8E=9F?= =?UTF-8?q?=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clientApi/llmModelCatalog 改用 @genarrative/shared 生成类型,自定义目录补 unavailableModels 空桶与 protocol - 新增 modelAvailabilityCopy.ts:锁定项原因文案与按 reason 的降级提示 - ConversationModelSelect 渲染锁定项(不可点、可 focus、data-tooltip),降级提示按 reason 取值 - styles.css 补锁定项与 tooltip 样式(仅 hover / focus-visible) - 新增 vitest 覆盖文案映射与锁定项渲染,vitest.config 纳入 features/project-workspace 用例 --- .../ConversationModelSelect.test.tsx | 123 ++++++++++++++++++ .../ConversationModelSelect.tsx | 43 +++++- .../modelAvailabilityCopy.test.ts | 29 +++++ .../modelAvailabilityCopy.ts | 38 ++++++ .../src/services/clientApi.ts | 53 +++----- .../src/services/llmModelCatalog.ts | 11 +- apps/ai-game-creator-shell/src/styles.css | 46 +++++++ vitest.config.ts | 3 + 8 files changed, 311 insertions(+), 35 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/modelAvailabilityCopy.test.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/modelAvailabilityCopy.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx new file mode 100644 index 000000000..ab1accc17 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx @@ -0,0 +1,123 @@ +/** @vitest-environment jsdom */ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + catalog: { + defaultModelId: 'basic-model', + models: [ + { + id: 'basic-model', + displayName: '基础模型', + agentMode: 'codex' as const, + protocol: 'openai_responses' as const, + }, + ], + unavailableModels: [ + { + id: 'pro-model', + displayName: '专业模型', + agentMode: 'codex' as const, + protocol: 'openai_responses' as const, + reason: 'plan_required' as const, + }, + { + id: 'retired-model', + displayName: '下线模型', + agentMode: 'cc' as const, + protocol: 'anthropic' as const, + reason: 'disabled' as const, + }, + ], + revision: 1, + }, +})); + +vi.mock('../../app/tauri', () => ({ + resolveTauriInvoke: () => mocks.invoke, +})); + +vi.mock('../../services/llmModelCatalog', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + cachedLlmModelCatalog: () => mocks.catalog, + refreshLlmModelCatalog: () => Promise.resolve(mocks.catalog), + }; +}); + +import { ConversationModelSelect } from './ConversationModelSelect'; + +function mockConfig(selectedModelId: string, isDefault: boolean) { + mocks.invoke.mockImplementation(async (command: string) => { + if (command === 'read_game_creator_app_config') { + return { + config: { + selectedModelId, + selectedModelIsDefault: isDefault, + }, + }; + } + if (command === 'select_game_creator_model') { + return { + config: { + selectedModelId: 'basic-model', + selectedModelIsDefault: true, + }, + }; + } + return {}; + }); +} + +afterEach(() => { + cleanup(); + mocks.invoke.mockReset(); +}); + +describe('ConversationModelSelect 锁定模型', () => { + it('不可用模型渲染为不可点、可聚焦的原因项', async () => { + mockConfig('basic-model', true); + render(); + // 等挂载时的目录同步落地,避免把异步状态更新留到断言之后。 + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith('read_game_creator_app_config'), + ); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + + const planLocked = screen.getByText('专业模型').closest('[role="option"]'); + expect(planLocked?.tagName).toBe('DIV'); + expect(planLocked?.getAttribute('aria-disabled')).toBe('true'); + expect(planLocked?.getAttribute('tabindex')).toBe('0'); + expect(planLocked?.getAttribute('data-reason')).toBe('plan_required'); + expect(planLocked?.getAttribute('data-tooltip')).toBe('订阅计划不支持'); + + const disabledLocked = screen + .getByText('下线模型') + .closest('[role="option"]'); + expect(disabledLocked?.getAttribute('data-tooltip')).toBe('该模型已下线'); + }); + + it('已选模型落进不可用桶时按 reason 回退默认并提示', async () => { + mockConfig('retired-model', false); + render(); + + await waitFor(() => + expect(screen.getByRole('status').textContent).toBe( + '所选模型已下线,已切换为默认模型', + ), + ); + expect(mocks.invoke).toHaveBeenCalledWith( + 'select_game_creator_model', + expect.objectContaining({ modelId: 'basic-model', isDefault: true }), + ); + }); +}); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index 2bc9df75c..b763d77b6 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -1,4 +1,4 @@ -import { Check, ChevronDown, RefreshCcw } from 'lucide-react'; +import { Check, ChevronDown, Lock, RefreshCcw } from 'lucide-react'; import type { Ref } from 'react'; import { useCallback, @@ -14,10 +14,15 @@ import { cachedLlmModelCatalog, type ClientLlmModel, type ClientLlmModelCatalog, + type ClientLlmUnavailableModel, LLM_CONFIG_CHANGED_EVENT, LlmModelCatalogConfigError, refreshLlmModelCatalog, } from '../../services/llmModelCatalog'; +import { + unavailableModelFallbackNotice, + unavailableModelReasonLabel, +} from './modelAvailabilityCopy'; /** * 线协议与执行器正交;目录没下发时按执行器推导默认协议,保证发往后端的组合始终合法。 @@ -76,6 +81,9 @@ export function ConversationModelSelect({ const [models, setModels] = useState( initialCatalog?.models ?? [], ); + const [unavailableModels, setUnavailableModels] = useState< + ClientLlmUnavailableModel[] + >(initialCatalog?.unavailableModels ?? []); const [selected, setSelected] = useState(''); const [defaultModelId, setDefaultModelId] = useState( initialCatalog?.defaultModelId ?? '', @@ -143,6 +151,7 @@ export function ConversationModelSelect({ ) { appliedRevisionRef.current = catalog.revision; setModels(catalog.models); + setUnavailableModels(catalog.unavailableModels); setDefaultModelId(catalog.defaultModelId); } const invoke = resolveTauriInvoke(); @@ -178,7 +187,13 @@ export function ConversationModelSelect({ if (!next && defaultEnabled) { next = catalog.defaultModelId; nextIsDefault = true; - if (saved) nextNotice = '所选模型已停用,已切换为默认模型'; + if (saved) { + // 命中不可用桶时按 reason 给具体原因;两桶都没有(后台直接删除)走泛化文案。 + nextNotice = unavailableModelFallbackNotice( + catalog.unavailableModels.find((model) => model.id === saved) + ?.reason, + ); + } } if (next && (next !== saved || nextIsDefault !== followsDefault)) { let persisted: GameCreatorAppConfigView; @@ -253,6 +268,7 @@ export function ConversationModelSelect({ appliedRevisionRef.current = null; selectedRef.current = ''; setModels([]); + setUnavailableModels([]); setSelected(''); setDefaultModelId(''); setError(modelCatalogErrorMessage(error)); @@ -315,6 +331,7 @@ export function ConversationModelSelect({ selectedRef.current = ''; setSelected(''); setModels([]); + setUnavailableModels([]); void syncCatalog(true); } window.addEventListener('focus', handleWindowFocus); @@ -455,6 +472,28 @@ export function ConversationModelSelect({ ) : null} ))} + {/* 目录里存在但当前档位不可用的模型:不可点,仅 hover / 键盘 focus 出原因。 */} + {unavailableModels.map((model) => { + const reasonLabel = unavailableModelReasonLabel(model.reason); + return ( +
+ + {model.displayName} + +
+ ); + })}
{annual - ? `年实付 ${formatRechargePrice(plan.yearPriceCents)}${ - savingCents > 0 ? ` · 省 ${formatRechargePrice(savingCents)}` : '' - }` + ? annualPriceLine : `每次支付 ${formatRechargePrice(listPriceCents)},有效期 1 个月`}
From b431eb6074acfb02fba1acfd5af3439e5497ecb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 15:55:38 +0800 Subject: [PATCH 162/232] =?UTF-8?q?refactor(=E9=92=B1=E5=8C=85=20store):?= =?UTF-8?q?=20=E6=8A=BD=E5=87=BA=E4=BD=99=E9=A2=9D=E5=B0=B1=E7=BB=AA?= =?UTF-8?q?=E6=80=81=E7=BB=9F=E4=B8=80=E6=9E=84=E9=80=A0=EF=BC=8C=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E9=87=8D=E5=A4=8D=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - applyWalletBalanceSnapshot 与刷新循环各自重复「resolve 视图 + 写 4 个 ready 字段」 - 抽出 readyWalletBalanceState 统一派生 totalPoints / status / error,两条路径不再有漂移风险 - 行为不变(注:本文件既有的 rejects a snapshot from another owner 用例在改动前后均失败,与本次无关) --- .../src/stores/createProfileWalletStore.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/shared/src/stores/createProfileWalletStore.ts b/packages/shared/src/stores/createProfileWalletStore.ts index a2ec1eeeb..211ba8800 100644 --- a/packages/shared/src/stores/createProfileWalletStore.ts +++ b/packages/shared/src/stores/createProfileWalletStore.ts @@ -49,6 +49,20 @@ const EMPTY_WALLET_STATE = { mudPointBalanceError: '', } as const; +/** + * 余额读取成功后的统一就绪态:快照应用与刷新循环共用同一段派生逻辑, + * 避免 `totalPoints` 与 ready 字段在两条路径上各自漂移。 + */ +function readyWalletBalanceState(balance: ProfileMudPointBalance) { + const mudPointBalance = resolveProfileMudPointBalanceView(balance); + return { + mudPointBalance, + legacyWalletBalance: mudPointBalance.totalPoints, + mudPointBalanceStatus: 'ready' as const, + mudPointBalanceError: '', + }; +} + export function createProfileWalletStore( api: ProfileWalletApi, ): UseBoundStore> { @@ -119,13 +133,7 @@ export function createProfileWalletStore( snapshot.operationSequence, ); settledRefreshVersion = snapshot.invalidationVersion; - const mudPointBalance = resolveProfileMudPointBalanceView(balance); - set({ - mudPointBalance, - legacyWalletBalance: mudPointBalance.totalPoints, - mudPointBalanceStatus: 'ready', - mudPointBalanceError: '', - }); + set(readyWalletBalanceState(balance)); return true; }, onWalletBalanceMayHaveChanged: () => { @@ -160,15 +168,7 @@ export function createProfileWalletStore( } settledRefreshVersion = requestedVersion; - const mudPointBalance = resolveProfileMudPointBalanceView( - center.mudPointBalance, - ); - set({ - mudPointBalance, - legacyWalletBalance: mudPointBalance.totalPoints, - mudPointBalanceStatus: 'ready', - mudPointBalanceError: '', - }); + set(readyWalletBalanceState(center.mudPointBalance)); } catch { if (generation !== requestGeneration) { return; From 1e90254d0a6d0589e2e8263b700f5ff4e372e488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 15:57:22 +0800 Subject: [PATCH 163/232] =?UTF-8?q?perf(=E6=B3=A5=E7=82=B9):=20=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E6=A8=A1=E5=9D=97=E7=BA=A7=20Intl.DateTimeFormat=20?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E5=88=B7=E6=96=B0=E6=97=B6=E5=88=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - formatMudPointResetAt 原先每次调用都新建 Intl.DateTimeFormat,而它跑在多个渲染路径里 - locale / 时区 / 字段恒定,提升为模块级常量 MUD_POINT_RESET_FORMATTER 复用,输出不变 --- packages/shared/src/utils/mudPoints.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/utils/mudPoints.ts b/packages/shared/src/utils/mudPoints.ts index d65ec3ba3..080bdfbd8 100644 --- a/packages/shared/src/utils/mudPoints.ts +++ b/packages/shared/src/utils/mudPoints.ts @@ -64,6 +64,22 @@ export function shouldShowProfileDailyFreePool( ); } +/** + * 月度泥点刷新时刻的格式化器:locale / 时区 / 字段固定,提到模块级复用。 + * + * 固定 `Asia/Shanghai` 与 ADR「会员账期按开通日自然月」一致;`hourCycle: 'h23'` 避免 + * 午夜格式化成 24 点。该 formatter 会在多个渲染路径里被反复调用,构建一次即可。 + */ +const MUD_POINT_RESET_FORMATTER = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', +}); + /** * 月度泥点的刷新时刻展示文案。 * @@ -80,15 +96,7 @@ export function formatMudPointResetAt( if (Number.isNaN(parsed.getTime())) { return null; } - const parts = new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - hourCycle: 'h23', - }).formatToParts(parsed); + const parts = MUD_POINT_RESET_FORMATTER.formatToParts(parsed); const lookup = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? '00'; return `${lookup('year')}-${lookup('month')}-${lookup('day')} ${lookup('hour')}:${lookup( From 9476290c4bea5fbc3612c4966b85771a7aa6fb36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 16:00:33 +0800 Subject: [PATCH 164/232] =?UTF-8?q?refactor(LLM):=20=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=97=A0=E5=8F=AF=E7=94=A8=E6=A8=A1=E5=9E=8B=E6=97=B6=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=20AgcModelResolveError=20=E7=9A=84=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - public_model_catalog 里硬编码的 MODEL_UNAVAILABLE 与 AgcModelResolveError::Unavailable.code() 重复 - 改为直接取枚举 code(),与 MODEL_ACCESS_UNAVAILABLE 的既有做法一致,避免两处字符串漂移 --- server-rs/crates/api-server/src/llm/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 28c275fb8..b12bbb318 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -281,7 +281,7 @@ fn public_model_catalog( .default_model_id_for(access) .ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) - .with_code("MODEL_UNAVAILABLE") + .with_code(AgcModelResolveError::Unavailable.code()) .with_message("当前会员档位没有可用模型") })? .to_string(); From 55bbf27e8fa112d38e843a8eb2e8b1a9c9cd6eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 17:12:55 +0800 Subject: [PATCH 165/232] =?UTF-8?q?fix(=E5=85=85=E5=80=BC=E5=BC=B9?= =?UTF-8?q?=E7=AA=97):=20=E5=BD=93=E5=89=8D=E6=A1=A3=E4=BD=8D=E6=94=B9?= =?UTF-8?q?=E4=BB=8E=E6=9C=AA=E8=BF=87=E6=BB=A4=E7=9B=AE=E5=BD=95=E8=A7=A3?= =?UTF-8?q?=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 有效会员的当前档位被后台临时禁用时,filter 后的 membershipPlans 查不到,状态卡退化成「会员」、按钮变「开通会员」,可在有效会员上重复开通(评审第 5 条) - 结算弹窗升级摘要的 currentPlan 同样取自过滤列表,禁用档位会丢「原套餐标准价」;改由父组件传入未过滤目录解析出的 currentPlan,并移除弹窗对 membership 的无用依赖(评审第 11 条) - 新增回归:active 会员 + 当前档 disabled 时仍显示「当前 Plus · 年付」、Max 提供「补差升级」,摘要保留「原套餐标准年价」 --- .../index.test.tsx | 44 +++++++++++++++++++ .../PlatformProfileRechargeModal/index.tsx | 17 ++++--- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx index fbea85958..afb55f7f1 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx @@ -261,6 +261,50 @@ describe('PlatformProfileRechargeModal', () => { expect(onSubmitMembershipCheckout).toHaveBeenCalledWith('max', 'yearly'); }); + test('keeps an active member as member when their current plan is disabled', async () => { + const user = userEvent.setup(); + const onRequestMembershipQuote = vi.fn(); + + render( + , + ); + + // 状态卡仍认得当前档位,而不是退化成「会员」。 + expect(screen.getByText('当前 Plus · 年付')).toBeTruthy(); + // 有效会员看到的是补差升级,而不是可在有效会员上重复开通的「开通会员」。 + await user.click(screen.getByRole('button', { name: 'Max 年付 补差升级' })); + expect(onRequestMembershipQuote).toHaveBeenCalledWith('max'); + + const checkout = screen.getByRole('dialog', { name: '确认套餐变更' }); + // 即使原档位已禁用,升级摘要也要给出「原套餐标准年价」。 + expect(within(checkout).getByText('原套餐标准年价')).toBeTruthy(); + expect(within(checkout).getByText('¥990')).toBeTruthy(); + }); + test('shows a placeholder instead of the list price while the upgrade quote is loading', async () => { const user = userEvent.setup(); diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index c81eb11f6..e8bd6abea 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -936,7 +936,7 @@ function PlatformPointProductGrid({ function PlatformRechargeCheckoutDialog({ target, membershipPlans, - membership, + currentPlan, membershipQuote, isLoadingMembershipQuote, membershipQuoteError, @@ -949,7 +949,8 @@ function PlatformRechargeCheckoutDialog({ }: { target: PlatformProfileRechargeCheckoutTarget; membershipPlans: ProfileMembershipPlanRecord[]; - membership: ProfileMembership | null; + /** 会员当前档位;来自未过滤目录,档位被后台临时禁用时仍保留「原套餐标准价」。 */ + currentPlan: ProfileMembershipPlanRecord | null; membershipQuote: ProfileMembershipUpgradeQuote | null; isLoadingMembershipQuote: boolean; membershipQuoteError: string | null; @@ -1001,8 +1002,6 @@ function PlatformRechargeCheckoutDialog({ ? (plan?.yearPriceCents ?? 0) : (plan?.monthPriceCents ?? 0); const isUpgrade = target.mode === 'upgrade'; - const currentPlan = - membershipPlans.find((item) => item.plan === membership?.plan) ?? null; const productId = resolveMembershipProductId(target.plan, target.cycleKind); const submitting = submittingProductId === productId; const quoteForTarget = @@ -1317,10 +1316,14 @@ export function PlatformProfileRechargeModal({ () => center?.pointProducts ?? [], [center?.pointProducts], ); + // 当前档位必须从「未过滤目录」里查:会员有效期内若该档被后台临时禁用, + // 过滤后的 membershipPlans 查不到会把有效会员误判成非会员(状态卡丢标题、按钮变「开通会员」)。 const currentPlanRecord = useMemo( () => - membershipPlans.find((plan) => plan.plan === membership?.plan) ?? null, - [membership?.plan, membershipPlans], + (center?.membershipPlans ?? []).find( + (plan) => plan.plan === membership?.plan, + ) ?? null, + [center?.membershipPlans, membership?.plan], ); const [activeTab, setActiveTab] = useState('subscription'); @@ -1546,7 +1549,7 @@ export function PlatformProfileRechargeModal({ Date: Sun, 4 Oct 2026 17:41:10 +0800 Subject: [PATCH 166/232] =?UTF-8?q?fix(LLM):=20=E3=80=8C=E8=AF=A5=E6=A1=A3?= =?UTF-8?q?=E6=97=A0=E5=8F=AF=E7=94=A8=E6=A8=A1=E5=9E=8B=E3=80=8D=E8=A1=A5?= =?UTF-8?q?=E4=B8=93=E7=94=A8=E5=8F=98=E4=BD=93=E5=B9=B6=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=B8=BA=20503?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AgcModelResolveError 新增 NoModelForTier,与「某模型不可用」/「档位不够」区分;code() 仍复用 MODEL_UNAVAILABLE - 代理路径 resolve_requested_for(None, access) 不再折叠成 Unavailable,agc_model_resolve_error 把 NoModelForTier 映射到 503,与 /api/llm/models 列表端点一致(评审第 8 条) - public_model_catalog 直接复用 agc_model_resolve_error(NoModelForTier),消除手写 503 + 字面量错误码 - 更新 model_access.rs 契约注释与 module-runtime / api-server 测试 --- server-rs/crates/api-server/src/llm/mod.rs | 19 +++++++++----- .../crates/api-server/src/llm/model_access.rs | 3 ++- .../module-runtime/src/agc_model_access.rs | 26 ++++++++++++++----- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index b12bbb318..0ca4eea09 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -127,6 +127,14 @@ mod model_catalog_tests { assert_eq!(unavailable.status_code(), StatusCode::UNPROCESSABLE_ENTITY); } + #[test] + fn tier_without_model_maps_to_service_unavailable_with_model_unavailable_code() { + // 与 `/api/llm/models` 列表端点同一条件、同一状态码:代理路径不能再落到 422。 + let error = agc_model_resolve_error(module_runtime::AgcModelResolveError::NoModelForTier); + assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(error.code(), "MODEL_UNAVAILABLE"); + } + #[test] fn access_lookup_failure_maps_to_service_unavailable_with_dedicated_code() { // 读链路失败必须是独立的 503 + 专用错误码,不能与 403 档位不足 / 422 模型不可用混用, @@ -277,13 +285,10 @@ fn public_model_catalog( ) -> Result { let OwnerAgcModelCatalog { catalog, access } = owner_catalog; // 该档一个可用模型都没有属于后台目录配置问题:明确报错,而不是下发一个不可解析的默认项。 + // 与代理路径共用 `agc_model_resolve_error`,保证同一条件在两个端点都是 503 + MODEL_UNAVAILABLE。 let default_model_id = catalog .default_model_id_for(access) - .ok_or_else(|| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) - .with_code(AgcModelResolveError::Unavailable.code()) - .with_message("当前会员档位没有可用模型") - })? + .ok_or_else(|| agc_model_resolve_error(AgcModelResolveError::NoModelForTier))? .to_string(); let models = catalog .available_models_for(access) @@ -370,11 +375,13 @@ async fn load_owner_llm_catalog( }) } -/// 模型解析错误的统一映射:档位不够是 403 + 专用错误码,目录外 / 停用仍是 422。 +/// 模型解析错误的统一映射:档位不够是 403 + 专用错误码,目录外 / 停用是 422; +/// 「该档没有任何可用模型」是目录 / 档位配置问题,按 503 处理,和 `/api/llm/models` 列表端点一致。 fn agc_model_resolve_error(error: AgcModelResolveError) -> AppError { let status = match error { AgcModelResolveError::NotAvailableForPlan => StatusCode::FORBIDDEN, AgcModelResolveError::Unavailable => StatusCode::UNPROCESSABLE_ENTITY, + AgcModelResolveError::NoModelForTier => StatusCode::SERVICE_UNAVAILABLE, }; AppError::from_status(status) .with_code(error.code()) diff --git a/server-rs/crates/api-server/src/llm/model_access.rs b/server-rs/crates/api-server/src/llm/model_access.rs index c5632c36b..3e538ee05 100644 --- a/server-rs/crates/api-server/src/llm/model_access.rs +++ b/server-rs/crates/api-server/src/llm/model_access.rs @@ -5,7 +5,8 @@ //! //! LLM 路由对外错误码(三条互不重叠,客户端据此区分处理): //! - `MODEL_NOT_AVAILABLE_FOR_PLAN`(403):模型存在且启用,但当前会员档位不够(`module-runtime` 解析)。 -//! - `MODEL_UNAVAILABLE`(422,列表无可用模型时 503):模型不在目录、已停用,或该档在目录里没有任何可用模型。 +//! - `MODEL_UNAVAILABLE`(422;该档在目录里没有任何可用模型时 503):模型不在目录或已停用; +//! 「整个档位没有可用模型」属于目录 / 档位配置问题,列表端点与代理端点统一按 503 上报。 //! - `MODEL_ACCESS_UNAVAILABLE`(503):读取账号权限档本身失败(SpacetimeDB 传输 / procedure / 鉴权)。 //! 注意**缺会员行 / 缺档位目录行不算这条**:module procedure 已按 `Basic` 失败关闭并返回 `ok: true`。 //! 能走到这条说明读链路不可用,不能把付费账号静默降级成 `Basic`。 diff --git a/server-rs/crates/module-runtime/src/agc_model_access.rs b/server-rs/crates/module-runtime/src/agc_model_access.rs index a29e5793d..4d4285aa7 100644 --- a/server-rs/crates/module-runtime/src/agc_model_access.rs +++ b/server-rs/crates/module-runtime/src/agc_model_access.rs @@ -69,16 +69,18 @@ impl From for AgcModelAccess { /// 模型解析失败原因;调用方据此选择错误码与 HTTP 状态,不靠字符串匹配。 #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AgcModelResolveError { - /// 标识不在目录、模型已停用,或该档位没有任何可用模型。 + /// 标识不在目录、模型已停用。 Unavailable, /// 模型存在且启用,但当前会员档位不够(越权)。 NotAvailableForPlan, + /// 该会员档位在目录里没有任何可用模型:属于目录 / 档位配置问题,不是某个模型的问题。 + NoModelForTier, } impl AgcModelResolveError { pub const fn code(self) -> &'static str { match self { - Self::Unavailable => "MODEL_UNAVAILABLE", + Self::Unavailable | Self::NoModelForTier => "MODEL_UNAVAILABLE", Self::NotAvailableForPlan => MODEL_NOT_AVAILABLE_FOR_PLAN, } } @@ -87,6 +89,7 @@ impl AgcModelResolveError { match self { Self::Unavailable => "所选模型不可用,请刷新模型列表", Self::NotAvailableForPlan => "当前会员档位不能使用该模型,请升级会员或重新选择模型", + Self::NoModelForTier => "当前会员档位没有可用模型", } } } @@ -146,7 +149,7 @@ impl AgcModelCatalog { /// /// `requested` 为空或占位标识时用该档默认模型;显式标识必须是目录内、启用、且档位允许的模型。 /// 目录外 / 已停用 → [`AgcModelResolveError::Unavailable`];档位不够 → [`AgcModelResolveError::NotAvailableForPlan`], - /// **不静默回退**成其它模型。 + /// **不静默回退**成其它模型;该档没有任何可用模型 → [`AgcModelResolveError::NoModelForTier`]。 pub fn resolve_requested_for( &self, requested: Option<&str>, @@ -165,12 +168,12 @@ impl AgcModelCatalog { None => { let id = self .default_model_id_for(access) - .ok_or(AgcModelResolveError::Unavailable)?; + .ok_or(AgcModelResolveError::NoModelForTier)?; self.models .iter() .find(|model| model.id == id && model.is_available_for(access)) .map(|model| model.model_id.as_str()) - .ok_or(AgcModelResolveError::Unavailable) + .ok_or(AgcModelResolveError::NoModelForTier) } } } @@ -287,12 +290,17 @@ mod tests { } #[test] - fn plan_with_no_available_model_falls_back_to_unavailable() { + fn plan_with_no_available_model_reports_no_model_for_tier() { let mut catalog = catalog_from_ids(&["model-a"], 0); mark_full(&mut catalog, "model-a"); assert_eq!(catalog.default_model_id_for(AgcModelAccess::Basic), None); assert_eq!( catalog.resolve_requested_for(None, AgcModelAccess::Basic), + Err(AgcModelResolveError::NoModelForTier) + ); + // 显式点了目录外的模型仍是「该模型不可用」,不能和「整个档位没有模型」混为一谈。 + assert_eq!( + catalog.resolve_requested_for(Some("model-missing"), AgcModelAccess::Basic), Err(AgcModelResolveError::Unavailable) ); } @@ -307,6 +315,12 @@ mod tests { AgcModelResolveError::Unavailable.code(), "MODEL_UNAVAILABLE" ); + // 「该档没有可用模型」沿用 MODEL_UNAVAILABLE 错误码,但调用方按变体把它映射到 503, + // 与列表端点的状态保持一致。 + assert_eq!( + AgcModelResolveError::NoModelForTier.code(), + "MODEL_UNAVAILABLE" + ); } #[test] From bd06e42690c62835778b03f200e7f76829f22f2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 18:40:54 +0800 Subject: [PATCH 167/232] =?UTF-8?q?refactor(error):=20profile=20=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=88=86=E7=B1=BB=E5=85=A8=E9=9D=A2=E8=BD=AC=20typed?= =?UTF-8?q?=20error=EF=BC=8C=E5=88=A0=E9=99=A4=E6=96=87=E6=A1=88=E5=8C=B9?= =?UTF-8?q?=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module-runtime 新增 RuntimeProfileRewardCodeRedeemErrorCode(NotFound/Disabled/NotStarted/Expired/UsesExhausted/NotAllowedForUser/InvalidReward)与 RuntimeProfileProcedureRejectionCode(聚合会员 / 兑换码两类码) - RuntimeProfileRewardCodeRedeemProcedureResult 末尾追加 error_code,spacetime-module 用 ProfileRewardCodeRedeemProcedureError 在拒绝点显式赋码,入参与钱包错误保持无码 - SpacetimeClientError::ProcedureRejected.code 改为 RuntimeProfileProcedureRejectionCode,Display 输出 [code] message,不再吞掉机器可读原因(评审第 12 条) - api-server 的 map_runtime_profile_client_error 只读变体字段,AppError.code 取 code.as_str(),删除 is_runtime_profile_domain_error / is_runtime_profile_redeem_code_domain_error 两个中文文案匹配函数 - 重新生成 Rust bindings;同步技术设计 §5.8 与 decision-log --- .../shared-memory/decision-log.md | 9 ++ ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 34 +++-- .../crates/api-server/src/runtime_profile.rs | 124 ++++++++-------- server-rs/crates/module-runtime/src/domain.rs | 71 +++++++++ .../crates/spacetime-client/src/active.rs | 13 +- .../src/active/mapper/runtime_profile.rs | 37 ++++- .../spacetime-client/src/module_bindings.rs | 6 +- ...file_reward_code_redeem_error_code_type.rs | 28 ++++ ...eward_code_redeem_procedure_result_type.rs | 2 + .../src/runtime/active/profile.rs | 138 ++++++++++++++++-- 10 files changed, 375 insertions(+), 87 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_error_code_type.rs diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 385c81cd4..767ef029a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9674,3 +9674,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:选 ①。`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、本文件。 + +## 2026-10-03 兑换码链路补机器可读错误码(typed error 收口) + +- 背景:评审第 12 项指出 `SpacetimeClientError::ProcedureRejected` 的 `Display` 丢掉 `code`,而 `api-server/src/runtime_profile.rs` 的 `is_runtime_profile_redeem_code_domain_error` 仍在精确匹配 7 条中文 `error_message` 来判断兑换码拒绝该返回 400 还是 502,文案一改就静默退化,与会员链路已落地的 typed error 不一致。 +- 决策(module 侧赋码):`module-runtime` 新增 `RuntimeProfileRewardCodeRedeemErrorCode`(`NotFound` / `Disabled` / `NotStarted` / `Expired` / `UsesExhausted` / `NotAllowedForUser` / `InvalidReward`);`RuntimeProfileRewardCodeRedeemProcedureResult` 末尾追加 `error_code: Option<...>`;`spacetime-module` 用 `ProfileRewardCodeRedeemProcedureError::{Classified, Plain}` 在拒绝点显式赋码,入参与钱包类错误保持 `error_code = null`。 +- 决策(客户端 / BFF):`SpacetimeClientError::ProcedureRejected` 的 `code` 改为 `RuntimeProfileProcedureRejectionCode`(聚合 `Membership` 与 `RewardCodeRedeem` 两类),`Display` 输出 `[code] message` 保留机器原因;`api-server` 的 `map_runtime_profile_client_error` 只读变体的 `code` / `message`,`AppError.code` 取 `code.as_str()`,删除 `is_runtime_profile_domain_error` 与 `is_runtime_profile_redeem_code_domain_error` 两个文案匹配函数。 +- 决策(行为变化):兑换码的 `兑换码不存在` / `已停用` / `未生效` / `已过期` / `次数已用完` / `不适用于当前账号` / `奖励无效` 由原来的 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`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §5.8、本文件。 +- 验证:`npm run spacetime:generate`;`cargo check -p api-server`;`cargo test -p api-server runtime_profile::tests` 41 passed;`cargo test -p module-runtime`、`cargo test -p spacetime-client`、`cargo test -p spacetime-module` 全通过;`npm run check:spacetime-schema`、`npm run check:encoding`、`git diff --check` 通过。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index aae4bbf1a..c4f3dfde1 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -433,23 +433,31 @@ flowchart TD - `npm run check:generated-bindings` 以「重新生成并与工作区逐字节比较」做门禁,防止 Rust 改了而生成文件没重跑。 - 需要展示用派生字段(如 `totalPoints` 合计)时,在前端 `utils/mudPoints.ts` 里派生,不改 DTO。 -### 5.8 会员错误码契约(typed error,已落地) +### 5.8 Profile 错误码契约(typed error,已全面落地) -- 会员链路的业务拒绝不再靠中文 `error_message` 前缀分类。`module-runtime` 新增 - `RuntimeProfileMembershipErrorCode`(`UpgradeRejected` / `NotMember` / `AlreadyActive` / - `CycleKindLocked` / `MembershipExpired` / `StateChanged` / `PlanNotPurchasable` / - `PlanCatalogMissing` / `ProductIdUnparsable` / `MembershipProductRetired` / `InvalidPlanConfig`), - 在拒绝点显式产出,`RuntimeProfileMembershipUpgradeQuoteProcedureResult` / +- Profile 链路的业务拒绝不再靠中文 `error_message` 分类,全部由 procedure 结果里的机器可读 + `error_code` 透传。 +- 会员链路:`module-runtime` 的 `RuntimeProfileMembershipErrorCode`(`UpgradeRejected` / + `NotMember` / `AlreadyActive` / `CycleKindLocked` / `MembershipExpired` / `StateChanged` / + `PlanNotPurchasable` / `PlanCatalogMissing` / `ProductIdUnparsable` / + `MembershipProductRetired` / `InvalidPlanConfig`)在拒绝点显式产出, + `RuntimeProfileMembershipUpgradeQuoteProcedureResult` / `RuntimeProfileRechargeCenterProcedureResult` / `RuntimeProfileMembershipPlanAdminProcedureResult` 末尾追加 `error_code: Option`(`error_message` 保留人读文案)。 -- `spacetime-client` 的 `SpacetimeClientError` 新增 - `ProcedureRejected { code, message }`;`api-server` 的 `runtime_profile` 映射据此返回 400 - (`provider = runtime-profile`),基础设施错误保持 502。字段校验类错误(如 user_id 缺失) - 不产出 `error_code`,仍按字符串错误落到 502。 -- 行为变化:`会员目录缺少档位 ...` 与「已迁移的会员商品」原先落到 502,现在有明确错误码,统一 400。 +- 兑换码链路:`module-runtime` 的 `RuntimeProfileRewardCodeRedeemErrorCode`(`NotFound` / + `Disabled` / `NotStarted` / `Expired` / `UsesExhausted` / `NotAllowedForUser` / + `InvalidReward`)在拒绝点显式产出,`RuntimeProfileRewardCodeRedeemProcedureResult` 末尾同样追加 + `error_code`。 +- `spacetime-client` 的 `SpacetimeClientError::ProcedureRejected { code, message }` 用 + `RuntimeProfileProcedureRejectionCode` 聚合两条链路的 typed code;`api-server` 的 `runtime_profile` + 映射直接读变体字段返回 400(`provider = runtime-profile`),`AppError.code` 取 + `code.as_str()`,基础设施错误保持 502。字段校验类错误(如 user_id 缺失)不产出 `error_code`, + 仍按字符串错误落到 502。 +- 行为变化:`会员目录缺少档位 ...`、「已迁移的会员商品」以及兑换码的 `兑换码不存在` / `已停用` / + `未生效` / `已过期` / `次数已用完` / `不适用于当前账号` / `奖励无效` 原先落到 502,现在有明确 + 错误码,统一 400。`api-server` 已删除 `is_runtime_profile_redeem_code_domain_error` 文案匹配函数。 - 兼容边界:procedure result 是 wire 结构,新增字段要求 module 与 api-server 同版本部署; - 旧 module 不返回 `error_code` 时,新 client 绑定反序列化会失败。兑换码链路本轮未改造, - `api-server` 仍保留一个兑换码文案匹配函数。 + 旧 module 不返回 `error_code` 时,新 client 绑定反序列化会失败。 --- diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 74b18d86c..6c90babbd 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1463,57 +1463,28 @@ pub async fn get_profile_play_stats( } fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError { - let is_domain_error = is_runtime_profile_domain_error(&error); - let (status, provider) = match &error { - SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-profile"), - SpacetimeClientError::ProcedureRejected { .. } => { - (StatusCode::BAD_REQUEST, "runtime-profile") + match &error { + // profile procedure 的业务拒绝:机器码与人读文案都取自 typed 变体, + // HTTP 状态、错误码和 message 全部结构化透传,不做任何字符串匹配或解析。 + SpacetimeClientError::ProcedureRejected { code, message } => { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_code(code.as_str()) + .with_message(message.clone()) + .with_details(json!({ + "provider": "runtime-profile", + "message": message, + })) } - SpacetimeClientError::Procedure(_) if is_domain_error => { - (StatusCode::BAD_REQUEST, "runtime-profile") - } - _ => (StatusCode::BAD_GATEWAY, "spacetimedb"), - }; - - let app_error = AppError::from_status(status).with_details(json!({ - "provider": provider, - "message": error.to_string(), - })); - if is_domain_error { - return app_error.with_message(error.to_string()); + SpacetimeClientError::Runtime(message) => AppError::from_status(StatusCode::BAD_REQUEST) + .with_details(json!({ + "provider": "runtime-profile", + "message": message, + })), + _ => AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })), } - app_error -} - -/// 模块侧的可读业务拒绝以 400 返回,基础设施错误保持 502。 -/// -/// 会员链路的拒绝已由 SpacetimeDB procedure 透传机器可读错误码,走 -/// [`SpacetimeClientError::ProcedureRejected`];兑换码尚未改造,仍按可读文案匹配(见 -/// [`is_runtime_profile_redeem_code_domain_error`])。 -fn is_runtime_profile_domain_error(error: &SpacetimeClientError) -> bool { - match error { - SpacetimeClientError::ProcedureRejected { .. } => true, - SpacetimeClientError::Procedure(message) => { - is_runtime_profile_redeem_code_domain_error(message) - } - _ => false, - } -} - -// TODO(refactor): 兑换码链路尚未接入 typed error code,仍靠中文文案匹配判 400; -// 后续按会员链路(RuntimeProfileMembershipErrorCode + SpacetimeClientError::ProcedureRejected) -// 的做法,让 module 侧兑换码拒绝也携带机器可读错误码。 -fn is_runtime_profile_redeem_code_domain_error(message: &str) -> bool { - matches!( - message, - "兑换码不存在" - | "兑换码已停用" - | "兑换码未生效" - | "兑换码已过期" - | "兑换次数已用完" - | "该兑换码不适用于当前账号" - | "兑换码奖励无效" - ) } fn runtime_profile_error_response(request_context: &RequestContext, error: AppError) -> Response { @@ -2521,7 +2492,8 @@ mod tests { RuntimeProfileMembershipRecord, RuntimeProfileMembershipStatus, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductKind, - RuntimeProfileRechargeProductRecord, RuntimeProfileWalletLedgerSourceType, + RuntimeProfileRechargeProductRecord, RuntimeProfileRewardCodeRedeemErrorCode, + RuntimeProfileWalletLedgerSourceType, }; use super::{ @@ -2537,13 +2509,44 @@ mod tests { #[test] fn redeem_code_domain_errors_map_to_client_errors_without_hiding_infrastructure_failures() { - for message in ["兑换码未生效", "兑换码已过期"] { - let error = map_runtime_profile_client_error(SpacetimeClientError::Procedure( - message.to_string(), - )); + // 兑换码拒绝与会员链路同构:机器码从 typed 变体取,文案只做人读展示,不参与分类。 + for (code, message) in [ + ( + RuntimeProfileRewardCodeRedeemErrorCode::NotStarted, + "兑换码未生效", + ), + ( + RuntimeProfileRewardCodeRedeemErrorCode::Expired, + "兑换码已过期", + ), + ( + RuntimeProfileRewardCodeRedeemErrorCode::NotFound, + "兑换码不存在", + ), + ( + RuntimeProfileRewardCodeRedeemErrorCode::Disabled, + "兑换码已停用", + ), + ( + RuntimeProfileRewardCodeRedeemErrorCode::UsesExhausted, + "兑换次数已用完", + ), + ( + RuntimeProfileRewardCodeRedeemErrorCode::NotAllowedForUser, + "该兑换码不适用于当前账号", + ), + ( + RuntimeProfileRewardCodeRedeemErrorCode::InvalidReward, + "兑换码奖励无效", + ), + ] { + let error = map_runtime_profile_client_error(SpacetimeClientError::ProcedureRejected { + code: code.into(), + message: message.to_string(), + }); - assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); - assert_eq!(error.code(), "BAD_REQUEST"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST, "{message}"); + assert_eq!(error.code(), code.as_str(), "{message}"); assert_eq!(error.message(), message); assert_eq!( error @@ -2565,6 +2568,12 @@ mod tests { .and_then(Value::as_str), Some("spacetimedb") ); + + // 旧实现按中文文案把「兑换码已过期」判成 400;typed 改造后同样的裸字符串必须落到 502。 + let legacy_string_error = map_runtime_profile_client_error( + SpacetimeClientError::Procedure("兑换码已过期".into()), + ); + assert_eq!(legacy_string_error.status_code(), StatusCode::BAD_GATEWAY); } #[test] @@ -2590,11 +2599,12 @@ mod tests { ), ] { let error = map_runtime_profile_client_error(SpacetimeClientError::ProcedureRejected { - code, + code: code.into(), message: message.to_string(), }); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST, "{message}"); + assert_eq!(error.code(), code.as_str(), "{message}"); assert_eq!(error.message(), message); assert_eq!( error diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 6477930ac..0bfe73391 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1517,6 +1517,75 @@ impl RuntimeProfileMembershipErrorCode { } } +/// 兑换码核销业务拒绝的机器可读错误码。 +/// +/// 与 [`RuntimeProfileMembershipErrorCode`] 同样的目的:核销失败的人读 `error_message` +/// 会随文案调整,调用方不得按中文字符串判断 HTTP 状态,每个拒绝点显式产出本枚举并随 +/// procedure 结果透传到 Axum 侧。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeProfileRewardCodeRedeemErrorCode { + /// 兑换码在目录里不存在。 + NotFound, + /// 兑换码已被后台停用。 + Disabled, + /// 兑换码尚未生效。 + NotStarted, + /// 兑换码已过期。 + Expired, + /// 兑换次数已用完。 + UsesExhausted, + /// 私有兑换码不适用于当前账号。 + NotAllowedForUser, + /// 兑换码奖励配置无效。 + InvalidReward, +} + +impl RuntimeProfileRewardCodeRedeemErrorCode { + pub fn as_str(&self) -> &'static str { + match self { + Self::NotFound => "not_found", + Self::Disabled => "disabled", + Self::NotStarted => "not_started", + Self::Expired => "expired", + Self::UsesExhausted => "uses_exhausted", + Self::NotAllowedForUser => "not_allowed_for_user", + Self::InvalidReward => "invalid_reward", + } + } +} + +/// profile procedure 业务拒绝的统一错误码:把会员与兑换码两条链路的 typed code 收在一处。 +/// +/// 只在 `spacetime-client` 进程内传递,不落表、不过 wire,因此不派生 `SpacetimeType`。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RuntimeProfileProcedureRejectionCode { + Membership(RuntimeProfileMembershipErrorCode), + RewardCodeRedeem(RuntimeProfileRewardCodeRedeemErrorCode), +} + +impl RuntimeProfileProcedureRejectionCode { + pub fn as_str(self) -> &'static str { + match self { + Self::Membership(code) => code.as_str(), + Self::RewardCodeRedeem(code) => code.as_str(), + } + } +} + +impl From for RuntimeProfileProcedureRejectionCode { + fn from(code: RuntimeProfileMembershipErrorCode) -> Self { + Self::Membership(code) + } +} + +impl From for RuntimeProfileProcedureRejectionCode { + fn from(code: RuntimeProfileRewardCodeRedeemErrorCode) -> Self { + Self::RewardCodeRedeem(code) + } +} + /// 会员领域错误:机器可读 `code` + 人读 `message`。 /// /// 只在模块内部与 procedure 结果装载之间传递,不落 SpacetimeDB 表,因此不派生 `SpacetimeType`。 @@ -2562,6 +2631,8 @@ pub struct RuntimeProfileRewardCodeRedeemProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + /// 核销业务拒绝的机器可读错误码;非业务错误(入参、钱包等)为 `None`。 + pub error_code: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index 5d275a1a5..8240f4a4d 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -234,9 +234,9 @@ pub enum SpacetimeClientError { Build(String), ConnectDropped, Procedure(String), - /// 会员 procedure 返回了带机器可读错误码的业务拒绝。 + /// profile procedure 返回了带机器可读错误码的业务拒绝(会员 / 兑换码核销)。 ProcedureRejected { - code: module_runtime::RuntimeProfileMembershipErrorCode, + code: module_runtime::RuntimeProfileProcedureRejectionCode, message: String, }, Runtime(String), @@ -998,11 +998,11 @@ impl SpacetimeClientError { } pub(crate) fn procedure_rejected( - code: module_runtime::RuntimeProfileMembershipErrorCode, + code: impl Into, message: Option, ) -> Self { Self::ProcedureRejected { - code, + code: code.into(), message: message.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), } } @@ -1089,7 +1089,10 @@ impl fmt::Display for SpacetimeClientError { Self::Build(message) | Self::Procedure(message) | Self::Runtime(message) => { f.write_str(message) } - Self::ProcedureRejected { message, .. } => f.write_str(message), + Self::ProcedureRejected { code, message } => { + // `code` 是排障与审计要看的机器可读原因,Display 不能把它吞掉。 + write!(f, "[{}] {}", code.as_str(), message) + } Self::ConnectDropped => f.write_str("SpacetimeDB 连接在返回结果前已断开"), Self::Timeout(stage) => { f.write_str(&format!("SpacetimeDB {} 调用超时", stage.as_str())) diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs index fda444938..51d067c9d 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs @@ -1115,7 +1115,14 @@ pub(crate) fn map_runtime_profile_reward_code_redeem_procedure_result( result: RuntimeProfileRewardCodeRedeemProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); + // 业务拒绝自带机器可读 error_code 时走 typed 变体;入参 / 钱包等错误仍按普通字符串错误返回。 + return Err(match result.error_code { + Some(code) => SpacetimeClientError::procedure_rejected( + map_runtime_profile_reward_code_redeem_error_code_back(code), + result.error_message, + ), + None => SpacetimeClientError::procedure_failed(result.error_message), + }); } let snapshot = result @@ -2108,6 +2115,34 @@ pub(crate) fn map_runtime_profile_membership_error_code_back( } } +pub(crate) fn map_runtime_profile_reward_code_redeem_error_code_back( + value: crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode, +) -> module_runtime::RuntimeProfileRewardCodeRedeemErrorCode { + match value { + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::NotFound => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::NotFound + } + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::Disabled => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::Disabled + } + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::NotStarted => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::NotStarted + } + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::Expired => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::Expired + } + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::UsesExhausted => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::UsesExhausted + } + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::NotAllowedForUser => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::NotAllowedForUser + } + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::InvalidReward => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::InvalidReward + } + } +} + pub(crate) fn map_runtime_profile_membership_model_access( value: module_runtime::RuntimeProfileMembershipModelAccess, ) -> crate::module_bindings::RuntimeProfileMembershipModelAccess { diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 55a56de2b..19783243f 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -488,9 +488,9 @@ pub mod get_game_distribution_my_review_and_return_procedure; pub mod get_game_distribution_version_and_return_procedure; pub mod get_llm_router_account_and_return_procedure; pub mod get_owner_game_distribution_version_and_return_procedure; -pub mod get_profile_agc_model_access_and_return_procedure; pub mod get_payment_order_and_return_procedure; pub mod get_payment_order_by_checkout_token_and_return_procedure; +pub mod get_profile_agc_model_access_and_return_procedure; pub mod get_profile_dashboard_procedure; pub mod get_profile_membership_snapshot_and_return_procedure; pub mod get_profile_membership_upgrade_quote_procedure; @@ -786,6 +786,7 @@ pub mod runtime_profile_redeem_code_admin_procedure_result_type; pub mod runtime_profile_redeem_code_admin_upsert_input_type; pub mod runtime_profile_redeem_code_mode_type; pub mod runtime_profile_redeem_code_snapshot_type; +pub mod runtime_profile_reward_code_redeem_error_code_type; pub mod runtime_profile_reward_code_redeem_input_type; pub mod runtime_profile_reward_code_redeem_procedure_result_type; pub mod runtime_profile_reward_code_redeem_snapshot_type; @@ -1369,9 +1370,9 @@ pub use get_game_distribution_my_review_and_return_procedure::get_game_distribut pub use get_game_distribution_version_and_return_procedure::get_game_distribution_version_and_return; pub use get_llm_router_account_and_return_procedure::get_llm_router_account_and_return; pub use get_owner_game_distribution_version_and_return_procedure::get_owner_game_distribution_version_and_return; -pub use get_profile_agc_model_access_and_return_procedure::get_profile_agc_model_access_and_return; pub use get_payment_order_and_return_procedure::get_payment_order_and_return; pub use get_payment_order_by_checkout_token_and_return_procedure::get_payment_order_by_checkout_token_and_return; +pub use get_profile_agc_model_access_and_return_procedure::get_profile_agc_model_access_and_return; pub use get_profile_dashboard_procedure::get_profile_dashboard; pub use get_profile_membership_snapshot_and_return_procedure::get_profile_membership_snapshot_and_return; pub use get_profile_membership_upgrade_quote_procedure::get_profile_membership_upgrade_quote; @@ -1667,6 +1668,7 @@ pub use runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileR pub use runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput; pub use runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode; pub use runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot; +pub use runtime_profile_reward_code_redeem_error_code_type::RuntimeProfileRewardCodeRedeemErrorCode; pub use runtime_profile_reward_code_redeem_input_type::RuntimeProfileRewardCodeRedeemInput; pub use runtime_profile_reward_code_redeem_procedure_result_type::RuntimeProfileRewardCodeRedeemProcedureResult; pub use runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_error_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_error_code_type.rs new file mode 100644 index 000000000..949523f68 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_error_code_type.rs @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileRewardCodeRedeemErrorCode { + NotFound, + + Disabled, + + NotStarted, + + Expired, + + UsesExhausted, + + NotAllowedForUser, + + InvalidReward, +} + +impl __sdk::InModule for RuntimeProfileRewardCodeRedeemErrorCode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs index dd8936d7e..13fb730fe 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs @@ -4,6 +4,7 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_reward_code_redeem_error_code_type::RuntimeProfileRewardCodeRedeemErrorCode; use super::runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -12,6 +13,7 @@ pub struct RuntimeProfileRewardCodeRedeemProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileRewardCodeRedeemProcedureResult { 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 4747f3201..50c720011 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -58,6 +58,74 @@ impl ProfileMembershipProcedureError { } } +/// 兑换码核销 procedure 的错误载体。 +/// +/// 业务拒绝携带机器可读 [`RuntimeProfileRewardCodeRedeemErrorCode`];入参、钱包等其它错误保持 +/// 普通字符串,透传时 `error_code` 为 `null`(与会员链路同一约定)。 +enum ProfileRewardCodeRedeemProcedureError { + Classified(RuntimeProfileRewardCodeRedeemErrorCode, String), + Plain(String), +} + +impl From for ProfileRewardCodeRedeemProcedureError { + fn from(message: String) -> Self { + Self::Plain(message) + } +} + +impl From<&str> for ProfileRewardCodeRedeemProcedureError { + fn from(message: &str) -> Self { + Self::Plain(message.to_string()) + } +} + +impl ProfileRewardCodeRedeemProcedureError { + fn classified( + code: RuntimeProfileRewardCodeRedeemErrorCode, + message: impl Into, + ) -> Self { + Self::Classified(code, message.into()) + } + + fn into_parts(self) -> (String, Option) { + match self { + Self::Classified(code, message) => (message, Some(code)), + Self::Plain(message) => (message, None), + } + } +} + +/// 把兑换码校验的字段错误收敛成机器可读错误码;未列出的错误按普通错误处理(`error_code = null`)。 +fn reward_code_redeem_error_from_field_error( + error: RuntimeProfileFieldError, +) -> ProfileRewardCodeRedeemProcedureError { + let code = match error { + RuntimeProfileFieldError::RedeemCodeDisabled => { + Some(RuntimeProfileRewardCodeRedeemErrorCode::Disabled) + } + RuntimeProfileFieldError::RedeemCodeNotStarted => { + Some(RuntimeProfileRewardCodeRedeemErrorCode::NotStarted) + } + RuntimeProfileFieldError::RedeemCodeExpired => { + Some(RuntimeProfileRewardCodeRedeemErrorCode::Expired) + } + RuntimeProfileFieldError::RedeemCodeUsesExhausted => { + Some(RuntimeProfileRewardCodeRedeemErrorCode::UsesExhausted) + } + RuntimeProfileFieldError::RedeemCodeNotAllowedForUser => { + Some(RuntimeProfileRewardCodeRedeemErrorCode::NotAllowedForUser) + } + RuntimeProfileFieldError::InvalidRedeemCodeReward => { + Some(RuntimeProfileRewardCodeRedeemErrorCode::InvalidReward) + } + _ => None, + }; + match code { + Some(code) => ProfileRewardCodeRedeemProcedureError::Classified(code, error.to_string()), + None => ProfileRewardCodeRedeemProcedureError::Plain(error.to_string()), + } +} + #[spacetimedb::table(accessor = profile_dashboard_state)] pub struct ProfileDashboardState { #[primary_key] @@ -2605,12 +2673,17 @@ pub fn redeem_profile_reward_code( ok: true, record: Some(record), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRewardCodeRedeemProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRewardCodeRedeemProcedureResult { + ok: false, + record: None, + error_message: Some(message), + error_code, + } + } } } @@ -3191,6 +3264,48 @@ mod tests { } } + #[test] + fn reward_code_redeem_field_errors_map_to_typed_codes() { + for (error, expected) in [ + ( + RuntimeProfileFieldError::RedeemCodeDisabled, + RuntimeProfileRewardCodeRedeemErrorCode::Disabled, + ), + ( + RuntimeProfileFieldError::RedeemCodeNotStarted, + RuntimeProfileRewardCodeRedeemErrorCode::NotStarted, + ), + ( + RuntimeProfileFieldError::RedeemCodeExpired, + RuntimeProfileRewardCodeRedeemErrorCode::Expired, + ), + ( + RuntimeProfileFieldError::RedeemCodeUsesExhausted, + RuntimeProfileRewardCodeRedeemErrorCode::UsesExhausted, + ), + ( + RuntimeProfileFieldError::RedeemCodeNotAllowedForUser, + RuntimeProfileRewardCodeRedeemErrorCode::NotAllowedForUser, + ), + ( + RuntimeProfileFieldError::InvalidRedeemCodeReward, + RuntimeProfileRewardCodeRedeemErrorCode::InvalidReward, + ), + ] { + let (message, code) = + reward_code_redeem_error_from_field_error(error.clone()).into_parts(); + + assert_eq!(code, Some(expected), "{message}"); + assert_eq!(message, error.to_string()); + } + + // 未列入兑换码拒绝的错误(如入参校验)必须保持无码,避免把基础设施错误误判成业务 400。 + let (_, code) = + reward_code_redeem_error_from_field_error(RuntimeProfileFieldError::MissingUserId) + .into_parts(); + assert_eq!(code, None); + } + #[test] fn wallet_ledger_sort_follows_balance_settlement_chain_when_payment_time_is_delayed() { let mut entries = vec![ @@ -7535,13 +7650,13 @@ fn redeem_profile_referral_invite_code_record( fn redeem_profile_reward_code_record( ctx: &ReducerContext, input: RuntimeProfileRewardCodeRedeemInput, -) -> Result { +) -> Result { let validated_input = build_runtime_profile_reward_code_redeem_input( input.user_id, input.code, input.redeemed_at_micros, ) - .map_err(|error| error.to_string())?; + .map_err(|error| ProfileRewardCodeRedeemProcedureError::Plain(error.to_string()))?; let redeemed_at = Timestamp::from_micros_since_unix_epoch(validated_input.redeemed_at_micros); let user_id = validated_input.user_id; let code = validated_input.code; @@ -7550,7 +7665,12 @@ fn redeem_profile_reward_code_record( .profile_redeem_code() .code() .find(&code) - .ok_or_else(|| "兑换码不存在".to_string())?; + .ok_or_else(|| { + ProfileRewardCodeRedeemProcedureError::classified( + RuntimeProfileRewardCodeRedeemErrorCode::NotFound, + "兑换码不存在", + ) + })?; let user_used_count = count_profile_redeem_code_user_usage(ctx, &code, &user_id); validate_runtime_profile_redeem_code_usage( @@ -7559,7 +7679,7 @@ fn redeem_profile_reward_code_record( user_used_count, validated_input.redeemed_at_micros, ) - .map_err(|error| error.to_string())?; + .map_err(reward_code_redeem_error_from_field_error)?; let usage_id = build_runtime_profile_redeem_code_usage_id( &code, From 3a955889f0a981f71b2282fef2e11f47eb001573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 19:14:31 +0800 Subject: [PATCH 168/232] =?UTF-8?q?refactor(=E5=90=8E=E5=8F=B0=E5=A5=91?= =?UTF-8?q?=E7=BA=A6):=20=E4=BC=9A=E5=91=98=E8=AF=A6=E6=83=85=20plan/statu?= =?UTF-8?q?s/cycle=5Fkind=20=E6=94=B6=E5=8F=A3=E4=B8=BA=20token=20?= =?UTF-8?q?=E6=9E=9A=E4=B8=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared-contracts 新增 ProfileMembershipStatusToken(normal/active) - AdminProfileMembershipPayload 的 plan/status/cycle_kind 由 String 改为 ProfileMembershipPlanToken/ProfileMembershipStatusToken/ProfileMembershipCycleKindToken,未知值在反序列化即被拒(评审第 13 条) - module-runtime 补 RuntimeProfileMembershipStatus -> ProfileMembershipStatusToken 的 From 映射 - api-server 的 map_membership 改为直接 .into(),去掉 as_str().to_string() --- .../crates/api-server/src/admin_recharge.rs | 6 +++--- server-rs/crates/module-runtime/src/domain.rs | 11 +++++++++++ server-rs/crates/shared-contracts/src/admin.rs | 7 ++++--- .../crates/shared-contracts/src/runtime.rs | 17 +++++++++++++++++ 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/server-rs/crates/api-server/src/admin_recharge.rs b/server-rs/crates/api-server/src/admin_recharge.rs index 805ca679c..1f64311a4 100644 --- a/server-rs/crates/api-server/src/admin_recharge.rs +++ b/server-rs/crates/api-server/src/admin_recharge.rs @@ -1014,9 +1014,9 @@ fn map_membership_order_change( fn map_membership(record: RuntimeProfileMembershipRecord) -> AdminProfileMembershipPayload { AdminProfileMembershipPayload { - plan: record.plan.as_str().to_string(), - status: record.status.as_str().to_string(), - cycle_kind: record.cycle_kind.as_str().to_string(), + plan: record.plan.into(), + status: record.status.into(), + cycle_kind: record.cycle_kind.into(), cycle_index: record.cycle_index, cycle_count: record.cycle_count, cycle_granted_points: record.cycle_granted_points, diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 0bfe73391..3dbbbe694 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1423,6 +1423,17 @@ impl From } } +impl From + for shared_contracts::runtime::ProfileMembershipStatusToken +{ + fn from(value: RuntimeProfileMembershipStatus) -> Self { + match value { + RuntimeProfileMembershipStatus::Normal => Self::Normal, + RuntimeProfileMembershipStatus::Active => Self::Active, + } + } +} + impl From for shared_contracts::runtime::ProfileMembershipModelAccessToken { diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index a0ff50bbd..adf1c2b46 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -5,6 +5,7 @@ use serde_json::Value; use crate::runtime::{ ProfileMembershipChangeKindToken, ProfileMembershipCycleKindToken, ProfileMembershipPlanToken, + ProfileMembershipStatusToken, }; #[cfg(any())] @@ -1335,9 +1336,9 @@ pub struct AdminUserDetailResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AdminProfileMembershipPayload { - pub plan: String, - pub status: String, - pub cycle_kind: String, + pub plan: ProfileMembershipPlanToken, + pub status: ProfileMembershipStatusToken, + pub cycle_kind: ProfileMembershipCycleKindToken, pub cycle_index: u32, pub cycle_count: u32, pub cycle_granted_points: u64, diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index fc576d464..3e7f3e3db 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -289,6 +289,23 @@ impl ProfileMembershipCycleKindToken { } } +/// 会员状态 token;与前端 `ProfileMembershipStatus` 联合类型一一对应。 +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ProfileMembershipStatusToken { + Normal, + Active, +} + +impl ProfileMembershipStatusToken { + pub fn as_str(&self) -> &'static str { + match self { + Self::Normal => "normal", + Self::Active => "active", + } + } +} + /// 模型权限 token;与前端 `ProfileMembershipModelAccess` 一一对应。 #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] From 54d1df9d2110d4369a03437b8b62a28661a09725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 19:35:28 +0800 Subject: [PATCH 169/232] =?UTF-8?q?refactor(=E5=A5=91=E7=BA=A6):=20?= =?UTF-8?q?=E4=BC=9A=E5=91=98=20token=20=E6=9E=9A=E4=B8=BE=E8=B5=B0=20ts-r?= =?UTF-8?q?s=20=E7=94=9F=E6=88=90=EF=BC=8C=E5=89=8D=E7=AB=AF=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=89=8B=E5=86=99=E8=81=94=E5=90=88=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared-contracts 给 ProfileMembershipPlanToken/CycleKindToken/StatusToken/ModelAccessToken/ChangeKindToken 补 ts_rs::TS + ts(export),生成 5 个 .ts 到 packages/shared/src/contracts/generated/(评审第 14 条) - packages/shared/src/contracts/runtime.ts 的 ProfileMembershipPlan/Status/CycleKind/ModelAccess 改为生成类型的别名并 re-export - apps/admin-web/src/api/adminApiTypes.ts 删除本地手写联合类型,改为从 @genarrative/shared 导入 / re-export - npm run check:generated-bindings、admin-web:typecheck、npm run typecheck 通过 --- apps/admin-web/src/api/adminApiTypes.ts | 24 ++++---- .../ProfileMembershipChangeKindToken.ts | 6 ++ .../ProfileMembershipCycleKindToken.ts | 6 ++ .../ProfileMembershipModelAccessToken.ts | 6 ++ .../generated/ProfileMembershipPlanToken.ts | 9 +++ .../generated/ProfileMembershipStatusToken.ts | 6 ++ packages/shared/src/contracts/runtime.ts | 28 ++++++---- .../crates/shared-contracts/src/runtime.rs | 55 +++++++++++++++++++ 8 files changed, 118 insertions(+), 22 deletions(-) create mode 100644 packages/shared/src/contracts/generated/ProfileMembershipChangeKindToken.ts create mode 100644 packages/shared/src/contracts/generated/ProfileMembershipCycleKindToken.ts create mode 100644 packages/shared/src/contracts/generated/ProfileMembershipModelAccessToken.ts create mode 100644 packages/shared/src/contracts/generated/ProfileMembershipPlanToken.ts create mode 100644 packages/shared/src/contracts/generated/ProfileMembershipStatusToken.ts diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index ceb6db413..65fe26188 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1,3 +1,9 @@ +import type { + ProfileMembershipCycleKind, + ProfileMembershipModelAccess, + ProfileMembershipPlan, +} from '@genarrative/shared'; + export interface ApiMeta { apiVersion?: string; requestId?: string; @@ -381,18 +387,12 @@ export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private'; export type ProfileTaskCycle = 'daily'; export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user'; export type ProfileRechargeProductKind = 'points' | 'membership'; -/** 会员档位:`normal` 是非会员占位档位,不可购买。 */ -export type ProfileMembershipPlan = - | 'normal' - | 'starter' - | 'plus' - | 'pro' - | 'max'; -/** 会员周期:有效期内不支持月年互换。 */ -export type ProfileMembershipCycleKind = 'monthly' | 'yearly'; -/** 模型权限:`basic` 仅基础模型,`full` 基础及高性能模型。 */ -export type ProfileMembershipModelAccess = 'basic' | 'full'; - +/** 会员档位 / 周期 / 权限:统一来自 `@genarrative/shared` 的生成类型,禁止手写字面量。 */ +export type { + ProfileMembershipCycleKind, + ProfileMembershipModelAccess, + ProfileMembershipPlan, +}; export interface AdminTrackingEventListQuery { eventKey?: string; userId?: string; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipChangeKindToken.ts b/packages/shared/src/contracts/generated/ProfileMembershipChangeKindToken.ts new file mode 100644 index 000000000..ea6b35979 --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMembershipChangeKindToken.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 会员订单权益变更类型 token;区分“新购”与“补差升级”。 + */ +export type ProfileMembershipChangeKindToken = "purchase" | "upgrade"; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipCycleKindToken.ts b/packages/shared/src/contracts/generated/ProfileMembershipCycleKindToken.ts new file mode 100644 index 000000000..878043568 --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMembershipCycleKindToken.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 会员购买周期 token;与前端 `ProfileMembershipCycleKind` 一一对应。 + */ +export type ProfileMembershipCycleKindToken = "monthly" | "yearly"; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipModelAccessToken.ts b/packages/shared/src/contracts/generated/ProfileMembershipModelAccessToken.ts new file mode 100644 index 000000000..1c3f3f95f --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMembershipModelAccessToken.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 模型权限 token;与前端 `ProfileMembershipModelAccess` 一一对应。 + */ +export type ProfileMembershipModelAccessToken = "basic" | "full"; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipPlanToken.ts b/packages/shared/src/contracts/generated/ProfileMembershipPlanToken.ts new file mode 100644 index 000000000..03de552cd --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMembershipPlanToken.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 会员档位 token;与前端 `ProfileMembershipPlan` 联合类型一一对应。 + * + * 用枚举而非 `String` 收口 wire 值:未知 token 在反序列化阶段即被拒绝,后台也不能再 + * 写入任意档位字符串。 + */ +export type ProfileMembershipPlanToken = "normal" | "starter" | "plus" | "pro" | "max"; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipStatusToken.ts b/packages/shared/src/contracts/generated/ProfileMembershipStatusToken.ts new file mode 100644 index 000000000..f2b14056e --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMembershipStatusToken.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 会员状态 token;与前端 `ProfileMembershipStatus` 联合类型一一对应。 + */ +export type ProfileMembershipStatusToken = "normal" | "active"; diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 3638f075a..d8bafe63e 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -1,7 +1,19 @@ import type { JsonObject } from './common'; +import type { ProfileMembershipChangeKindToken } from './generated/ProfileMembershipChangeKindToken'; +import type { ProfileMembershipCycleKindToken } from './generated/ProfileMembershipCycleKindToken'; +import type { ProfileMembershipModelAccessToken } from './generated/ProfileMembershipModelAccessToken'; +import type { ProfileMembershipPlanToken } from './generated/ProfileMembershipPlanToken'; +import type { ProfileMembershipStatusToken } from './generated/ProfileMembershipStatusToken'; import type { ProfileMudPointBalanceResponse as ProfileMudPointBalance } from './generated/ProfileMudPointBalanceResponse'; -export type { ProfileMudPointBalance }; +export type { + ProfileMembershipChangeKindToken, + ProfileMembershipCycleKindToken, + ProfileMembershipModelAccessToken, + ProfileMembershipPlanToken, + ProfileMembershipStatusToken, + ProfileMudPointBalance, +}; export const SAVE_SNAPSHOT_VERSION = 2; export const DEFAULT_MUSIC_VOLUME = 0.42; @@ -120,18 +132,14 @@ export type ExternalApiKeyMutationResponse = { }; export type ProfileRechargeProductKind = 'points' | 'membership'; -export type ProfileMembershipStatus = 'normal' | 'active'; +/** 会员状态:来源 Rust `ProfileMembershipStatusToken`,禁止手工维护字面量。 */ +export type ProfileMembershipStatus = ProfileMembershipStatusToken; /** 会员档位:`normal` 是非会员占位档位,不可购买。 */ -export type ProfileMembershipPlan = - | 'normal' - | 'starter' - | 'plus' - | 'pro' - | 'max'; +export type ProfileMembershipPlan = ProfileMembershipPlanToken; /** 会员周期:有效期内不支持月年互换,到期后重新选择。 */ -export type ProfileMembershipCycleKind = 'monthly' | 'yearly'; +export type ProfileMembershipCycleKind = ProfileMembershipCycleKindToken; /** 模型权限:`basic` 仅基础模型,`full` 基础及高性能模型。 */ -export type ProfileMembershipModelAccess = 'basic' | 'full'; +export type ProfileMembershipModelAccess = ProfileMembershipModelAccessToken; export type ProfileRechargeOrderStatus = | 'pending' | 'paid' diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 3e7f3e3db..d9b0b88b2 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -252,6 +252,17 @@ pub struct ProfileWalletLedgerResponse { /// 写入任意档位字符串。 #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub enum ProfileMembershipPlanToken { Normal, Starter, @@ -275,6 +286,17 @@ impl ProfileMembershipPlanToken { /// 会员购买周期 token;与前端 `ProfileMembershipCycleKind` 一一对应。 #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub enum ProfileMembershipCycleKindToken { Monthly, Yearly, @@ -292,6 +314,17 @@ impl ProfileMembershipCycleKindToken { /// 会员状态 token;与前端 `ProfileMembershipStatus` 联合类型一一对应。 #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub enum ProfileMembershipStatusToken { Normal, Active, @@ -309,6 +342,17 @@ impl ProfileMembershipStatusToken { /// 模型权限 token;与前端 `ProfileMembershipModelAccess` 一一对应。 #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub enum ProfileMembershipModelAccessToken { Basic, Full, @@ -326,6 +370,17 @@ impl ProfileMembershipModelAccessToken { /// 会员订单权益变更类型 token;区分“新购”与“补差升级”。 #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub enum ProfileMembershipChangeKindToken { Purchase, Upgrade, From cf2a8820d9e15b00ca8f19667fb350a08aeb30a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 19:41:09 +0800 Subject: [PATCH 170/232] =?UTF-8?q?fix(=E4=BC=9A=E5=91=98=E5=8D=87?= =?UTF-8?q?=E7=BA=A7):=20=E7=9B=B8=E7=AD=89=E4=BB=B7=E5=B7=AE=20/=20?= =?UTF-8?q?=E7=82=B9=E5=B7=AE=E4=B9=9F=E6=8B=92=E7=BB=9D=EF=BC=8C=E5=A0=B5?= =?UTF-8?q?=E4=BD=8F=200=20=E5=85=83=E5=8D=87=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - quote_runtime_profile_membership_upgrade 先把 target.price_cents / period_points 与 current 比较,<= 直接返回 InvalidQuoteInput(评审第 15 条) - 保留 rank 递增校验;旧实现用 checked_sub 只拦下溢,相等会算出 0 元升级 / 0 补点 - inverted_catalog_price_and_points_are_rejected 扩展相等分支,已验证去掉 <= 时该用例失败 --- .../module-runtime/src/membership/upgrade.rs | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index 051365bc5..64a9ae25a 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -182,15 +182,13 @@ pub fn quote_runtime_profile_membership_upgrade( input.now_micros, ); - // 目录价格 / 每期泥点必须严格随 rank 递增;倒挂时宁可报错,也不算出 0 元升级。 - let price_delta_cents = target - .price_cents - .checked_sub(current.price_cents) - .ok_or(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput)?; - let points_delta = target - .period_points - .checked_sub(current.period_points) - .ok_or(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput)?; + // 目录价格 / 每期泥点必须严格随 rank 递增;相等或倒挂都直接拒绝, + // 否则 checked_sub 会把「相等」算成 0 元升级 / 0 补点,违背目录不变量。 + if target.price_cents <= current.price_cents || target.period_points <= current.period_points { + return Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput); + } + let price_delta_cents = target.price_cents - current.price_cents; + let points_delta = target.period_points - current.period_points; let (amount_cents, granted_points_delta) = match target.cycle_kind { RuntimeProfileMembershipCycleKind::Monthly => (price_delta_cents, points_delta), @@ -494,6 +492,19 @@ mod tests { target.price_cents = current.price_cents + 1; target.period_points = current.period_points - 1; + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current: current.clone(), + target: target.clone(), + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + + // 中文注释:相等同样破坏「严格随 rank 递增」,必须拒绝;旧实现用 checked_sub + // 会把相等的价差 / 点差算成 0,从而产出 0 元升级 / 0 补点。 + target.price_cents = current.price_cents; + target.period_points = current.period_points; assert_eq!( quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { current, From b97d5597822f29f67e06eb05e842de05c47a23b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 19:47:38 +0800 Subject: [PATCH 171/232] =?UTF-8?q?fix(=E4=BC=9A=E5=91=98=E8=B4=A6?= =?UTF-8?q?=E6=9C=9F):=20=E6=9E=81=E7=AB=AF=E9=94=9A=E7=82=B9=E6=8E=A8?= =?UTF-8?q?=E8=BF=9B=E6=9C=88=E4=BB=BD=E9=A5=B1=E5=92=8C=E8=80=8C=E9=9D=9E?= =?UTF-8?q?=20panic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - advance_beijing_months_clamped 去掉 beijing_local_micros 后的 expect,改为安全年份区间 + unwrap_or 按方向饱和(评审第 16 条) - 目标年份超出 i64 微秒可表示范围(约 ±29 万年)时返回 i64::MAX / i64::MIN,避免历法乘法与 checked_* 溢出 panic - 新增 advance_saturates_instead_of_panicking_on_extreme_anchors 覆盖 i64::MAX / i64::MIN / u32::MAX --- .../module-runtime/src/membership/cycle.rs | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/server-rs/crates/module-runtime/src/membership/cycle.rs b/server-rs/crates/module-runtime/src/membership/cycle.rs index 2b29f37e6..c0ba36070 100644 --- a/server-rs/crates/module-runtime/src/membership/cycle.rs +++ b/server-rs/crates/module-runtime/src/membership/cycle.rs @@ -43,14 +43,37 @@ pub fn beijing_local_micros(year: i32, month: u8, day: u8, time_of_day_micros: i /// 把北京时间锚点推进 `months` 个自然月,日期夹取到目标月最后一天,时刻保持不变。 /// /// `months = 0` 返回锚点本身,因此调用方可以安全地用「第 n 期 = 推进 n 个自然月」表达账期。 +/// +/// 目标年份超出 i64 微秒可表示范围(约 ±29 万年)时按方向饱和到 `i64::MAX` / `i64::MIN`: +/// 该输入在物理上不可表示,饱和既保留「更晚 / 更早」的方向,又避免历法乘法与 +/// [`beijing_local_micros`] 的 `checked_*` 溢出让这个纯函数 panic。 pub fn advance_beijing_months_clamped(anchor_micros: i64, months: u32) -> i64 { + // 中文注释:`beijing_local_date_parts` 对极端 micros 会饱和后再反解,年份可能已被 i32 截断; + // 这里先按 i64 算目标年份,只在安全区间内才落回 i32 历法换算。 + const MIN_SAFE_YEAR: i64 = -300_000; + const MAX_SAFE_YEAR: i64 = 300_000; + let (year, month, day, time_of_day_micros) = beijing_local_date_parts(anchor_micros); let total_months = i64::from(year) * 12 + i64::from(month) - 1 + i64::from(months); - let target_year = total_months.div_euclid(12) as i32; + let target_year = total_months.div_euclid(12); let target_month = (total_months.rem_euclid(12) + 1) as u8; + if target_year < MIN_SAFE_YEAR { + return i64::MIN; + } + if target_year > MAX_SAFE_YEAR { + return i64::MAX; + } + let target_year = target_year as i32; let target_day = day.min(days_in_month(target_year, target_month)); - beijing_local_micros(target_year, target_month, target_day, time_of_day_micros) - .expect("夹取后的北京本地年月日与当日时刻必定合法") + beijing_local_micros(target_year, target_month, target_day, time_of_day_micros).unwrap_or_else( + || { + if anchor_micros < 0 { + i64::MIN + } else { + i64::MAX + } + }, + ) } /// 第 `index` 期(1-based)的账期窗口 `(cycle_started_at, cycle_resets_at)`。 @@ -98,6 +121,14 @@ mod tests { .expect("测试用的北京本地时间应当合法") } + #[test] + fn advance_saturates_instead_of_panicking_on_extreme_anchors() { + // 中文注释:极端锚点已无法精确表示目标月份,本用例只要求「不 panic 且方向不反转」。 + assert_eq!(advance_beijing_months_clamped(i64::MAX, 1), i64::MAX); + assert!(advance_beijing_months_clamped(i64::MIN, 1) < 0); + assert_eq!(advance_beijing_months_clamped(i64::MAX, u32::MAX), i64::MAX); + } + #[test] fn beijing_local_parts_round_trip() { let micros = beijing(2026, 10, 2, 9, 20); From 6424ad7e75a861240986b9c0b6af9ecff237aa5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 19:56:16 +0800 Subject: [PATCH 172/232] =?UTF-8?q?fix(LLM=20=E4=BB=A3=E7=90=86):=20Chat?= =?UTF-8?q?=20Completions=20=E9=80=8F=E4=BC=A0=E7=B1=BB=E5=9E=8B=E5=8C=96?= =?UTF-8?q?=E9=94=99=E8=AF=AF=EF=BC=8C=E4=BF=9D=E7=95=99=E6=9D=83=E9=99=90?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolve_llm_router_client 返回 Result<_, AppError> 而非 String(评审第 17 条) - load_owner_llm_catalog 的 503 + MODEL_ACCESS_UNAVAILABLE 不再被拍平成「模型目录暂不可用」 - resolve_requested_for 错误改走 agc_model_resolve_error,与 proxy_llm_responses / proxy_llm_messages 契约一致 - LlmConfig / LlmClient 构造失败按 500 返回;调用方直接 llm_error_response 透传 --- server-rs/crates/api-server/src/llm/mod.rs | 31 +++++++++++++--------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 0ca4eea09..ab4c6a8db 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -187,12 +187,7 @@ pub async fn proxy_llm_chat_completions( let (llm_client, key_id) = match resolve_llm_router_client(&state, authenticated.claims().user_id()).await { Ok((client, key_id)) => (client, key_id), - Err(error) => { - return Err(llm_error_response( - &request_context, - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(error), - )); - } + Err(error) => return Err(llm_error_response(&request_context, error)), }; prepare_llm_router_billing(&state, authenticated.claims().user_id()) .await @@ -1518,16 +1513,20 @@ fn is_responses_terminal_sse_event(event: &str) -> bool { async fn resolve_llm_router_client( state: &AppState, owner_user_id: &str, -) -> Result<(platform_llm::LlmClient, String), String> { - let (base_url, api_key, key_id) = resolve_llm_router_credentials(state, owner_user_id).await?; - let catalog = load_owner_llm_catalog(state, owner_user_id) +) -> Result<(platform_llm::LlmClient, String), AppError> { + let (base_url, api_key, key_id) = resolve_llm_router_credentials(state, owner_user_id) .await - .map_err(|_| "模型目录暂不可用".to_string())?; + .map_err(|message| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message) + })?; + // 目录读失败与会员权限读失败(503 + MODEL_ACCESS_UNAVAILABLE)都保留各自的类型化错误, + // 不再拍平成一句中文,与 `proxy_llm_responses` / `proxy_llm_messages` 的契约一致。 + let catalog = load_owner_llm_catalog(state, owner_user_id).await?; // Chat Completions 的 `model` 恒为账号该档的默认模型,不接收客户端选择。 let model = catalog .catalog .resolve_requested_for(None, catalog.access) - .map_err(|error| error.message().to_string())?; + .map_err(agc_model_resolve_error)?; let config = platform_llm::LlmConfig::new( platform_llm::LlmProvider::OpenAiCompatible, base_url.to_string(), @@ -1537,10 +1536,16 @@ async fn resolve_llm_router_client( state.config.llm_max_retries, state.config.llm_retry_backoff_ms, ) - .map_err(|error| error.to_string())?; + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("LLM Router 客户端配置非法:{error}")) + })?; platform_llm::LlmClient::new(config) .map(|client| (client, key_id)) - .map_err(|error| error.to_string()) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("创建 LLM Router 客户端失败:{error}")) + }) } async fn resolve_llm_router_credentials( From dcaab089cc5e93db67d6374afd68f1f67fdcbb4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 20:58:21 +0800 Subject: [PATCH 173/232] =?UTF-8?q?refactor(=E5=90=8E=E5=8F=B0=E5=A5=91?= =?UTF-8?q?=E7=BA=A6):=20AdminAgcModel.access=20=E6=94=B9=20ProfileMembers?= =?UTF-8?q?hipModelAccessToken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared-contracts 的 AdminAgcModel.access 由裸 String 改为 ProfileMembershipModelAccessToken,未知值在反序列化阶段被拒(评审第 18 条) - module-runtime 补 AgcModelAccess <-> ProfileMembershipModelAccessToken 双向 From - api-server 的 agc_models 去掉 AgcModelAccess::parse 兜底,改直接 From - admin-web 的 AdminAgcModelAccess 改为复用 @genarrative/shared 生成类型 - 扩展 catalog_dto 测试断言缺省 Basic 与未知 access 反序列化失败 --- apps/admin-web/src/api/adminApiTypes.ts | 3 ++- server-rs/crates/api-server/src/agc_models.rs | 22 ++++++++++++++----- .../module-runtime/src/agc_model_access.rs | 18 +++++++++++++++ .../crates/shared-contracts/src/admin.rs | 10 ++++----- 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 65fe26188..48dac87fe 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1174,7 +1174,8 @@ export interface AdminWalletRestrictionResponse { wallet: AdminProfileWalletPayload; } export type AdminAgcAgentMode = 'codex' | 'cc'; -export type AdminAgcModelAccess = 'basic' | 'full'; +/** 复用 `@genarrative/shared` 的生成类型,与后端 `ProfileMembershipModelAccessToken` 同源。 */ +export type AdminAgcModelAccess = ProfileMembershipModelAccess; export type AdminAgcModelProtocol = | 'openai_responses' | 'openai_chat' diff --git a/server-rs/crates/api-server/src/agc_models.rs b/server-rs/crates/api-server/src/agc_models.rs index 6f265632f..957dc9605 100644 --- a/server-rs/crates/api-server/src/agc_models.rs +++ b/server-rs/crates/api-server/src/agc_models.rs @@ -244,10 +244,7 @@ pub async fn admin_save_agc_models( .map_err(|message| { AppError::from_status(StatusCode::BAD_REQUEST).with_message(message) })?; - let access = AgcModelAccess::parse(&m.access).ok_or_else(|| { - AppError::from_status(StatusCode::BAD_REQUEST) - .with_message("权限档只允许 basic 或 full") - })?; + let access = AgcModelAccess::from(m.access); Ok(module_runtime::AgcModel { id: m.id, alias: m.alias, @@ -302,7 +299,7 @@ fn catalog_dto(catalog: AgcModelCatalog) -> AdminAgcModelCatalog { enabled: m.enabled, agent_mode: m.agent_mode.as_str().to_string(), protocol: Some(protocol), - access: m.access.as_str().to_string(), + access: m.access.into(), } }) .collect(), @@ -330,6 +327,21 @@ mod tests { })) .unwrap(); assert_eq!(legacy.agent_mode, "codex"); + assert_eq!( + legacy.access, + shared_contracts::runtime::ProfileMembershipModelAccessToken::Basic + ); + // 权限档 token 在反序列化阶段就收口,未知值不再推迟到 `AgcModelAccess::parse`。 + assert!( + serde_json::from_value::(json!({ + "id": "model-a", + "alias": "model-a", + "modelId": "model-a", + "enabled": true, + "access": "premium" + })) + .is_err() + ); } #[test] diff --git a/server-rs/crates/module-runtime/src/agc_model_access.rs b/server-rs/crates/module-runtime/src/agc_model_access.rs index 4d4285aa7..bf74ee07c 100644 --- a/server-rs/crates/module-runtime/src/agc_model_access.rs +++ b/server-rs/crates/module-runtime/src/agc_model_access.rs @@ -66,6 +66,24 @@ impl From for AgcModelAccess { } } +impl From for AgcModelAccess { + fn from(value: shared_contracts::runtime::ProfileMembershipModelAccessToken) -> Self { + match value { + shared_contracts::runtime::ProfileMembershipModelAccessToken::Basic => Self::Basic, + shared_contracts::runtime::ProfileMembershipModelAccessToken::Full => Self::Full, + } + } +} + +impl From for shared_contracts::runtime::ProfileMembershipModelAccessToken { + fn from(value: AgcModelAccess) -> Self { + match value { + AgcModelAccess::Basic => Self::Basic, + AgcModelAccess::Full => Self::Full, + } + } +} + /// 模型解析失败原因;调用方据此选择错误码与 HTTP 状态,不靠字符串匹配。 #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AgcModelResolveError { diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index adf1c2b46..96c1eebe6 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -4,8 +4,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::runtime::{ - ProfileMembershipChangeKindToken, ProfileMembershipCycleKindToken, ProfileMembershipPlanToken, - ProfileMembershipStatusToken, + ProfileMembershipChangeKindToken, ProfileMembershipCycleKindToken, + ProfileMembershipModelAccessToken, ProfileMembershipPlanToken, ProfileMembershipStatusToken, }; #[cfg(any())] @@ -1706,8 +1706,8 @@ fn default_codex_agent_mode() -> String { } /// 存量后台请求缺省为 `basic`(失败开放),只有人工标注才收紧到 `full`。 -fn default_basic_agc_model_access() -> String { - "basic".to_string() +fn default_basic_agc_model_access() -> ProfileMembershipModelAccessToken { + ProfileMembershipModelAccessToken::Basic } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] @@ -1726,7 +1726,7 @@ pub struct AdminAgcModel { pub protocol: Option, /// 该模型要求的会员档位:`basic`(缺省,对全部档位开放)/ `full`(后台人工标注)。 #[serde(default = "default_basic_agc_model_access")] - pub access: String, + pub access: ProfileMembershipModelAccessToken, } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] From 284b656b5985c6b1952be8cfe340fbf264d064d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 21:33:39 +0800 Subject: [PATCH 174/232] =?UTF-8?q?fix(=E5=85=85=E5=80=BC=E5=95=86?= =?UTF-8?q?=E5=93=81):=20=E7=A7=BB=E9=99=A4=E5=B7=B2=E9=80=80=E5=BD=B9?= =?UTF-8?q?=E7=9A=84=20bonus=5Fpoints=20/=20duration=5Fdays=20=E5=85=A5?= =?UTF-8?q?=E5=8F=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后台 upsert 请求 DTO 与 SpacetimeDB procedure 入参移除 bonus_points、duration_days(评审第 19 条) - 充点商品固定「¥1 = 10 泥点、无首充赠送」,module 落库统一写 0,保留退役列兼容存量 schema - 删除 InvalidRechargeProductFields 校验与错误文案;api-server / spacetime-client 同步收敛 - admin-web 表单移除「首充赠送」输入与状态,列表改为只展示基础泥点 - scripts/admin-web-fake-api.mjs 假数据同步 bonusPoints: 0 --- apps/admin-web/src/api/adminApiTypes.ts | 2 - .../src/pages/AdminRechargeProductPage.tsx | 47 +++++-------------- scripts/admin-web-fake-api.mjs | 17 +++---- .../crates/api-server/src/runtime_profile.rs | 2 - .../crates/module-runtime/src/commands.rs | 7 --- server-rs/crates/module-runtime/src/domain.rs | 2 - server-rs/crates/module-runtime/src/errors.rs | 4 -- .../crates/shared-contracts/src/runtime.rs | 2 - .../src/active/mapper/runtime_profile.rs | 2 - .../spacetime-client/src/active/runtime.rs | 4 -- ...echarge_product_admin_upsert_input_type.rs | 2 - .../src/runtime/active/profile.rs | 7 ++- 12 files changed, 21 insertions(+), 77 deletions(-) diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 48dac87fe..af2c826ef 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -680,8 +680,6 @@ export interface AdminUpsertProfileRechargeProductRequest { priceCents: number; kind: ProfileRechargeProductKind; pointsAmount: number; - bonusPoints: number; - durationDays: number; badgeLabel?: string | null; description?: string | null; enabled: boolean; diff --git a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx index d21f11de3..84c2d714c 100644 --- a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx @@ -19,11 +19,7 @@ import { } from '../api/adminApiClient'; import type { ProfileRechargeProductConfigAdminResponse } from '../api/adminApiTypes'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; -import { - handlePageError, - parseNonNegativeIntegerOrZero, - parsePositiveIntegerOrZero, -} from './pageUtils'; +import { handlePageError, parsePositiveIntegerOrZero } from './pageUtils'; interface AdminRechargeProductPageProps { token: string; @@ -38,7 +34,6 @@ const DEFAULT_RECHARGE_PRODUCT_FORM = { title: '60泥点', priceCents: '600', pointsAmount: '60', - bonusPoints: '0', badgeLabel: '', description: '60泥点', enabled: true, @@ -49,7 +44,7 @@ const DEFAULT_RECHARGE_PRODUCT_FORM = { * 泥点充值商品。 * * 会员档位已迁到独立的「会员档位」页(`profile_membership_plan`),本页只维护充点商品。 - * 新方案固定「¥1 = 10 泥点、无任何首充赠送」,首充赠送字段保留能力但默认 0。 + * 新方案固定「¥1 = 10 泥点、无首充赠送」,因此表单不再提供首充赠送 / 天数等已退役字段。 */ export function AdminRechargeProductPage({ token, @@ -70,9 +65,6 @@ export function AdminRechargeProductPage({ const [pointsAmount, setPointsAmount] = useState( DEFAULT_RECHARGE_PRODUCT_FORM.pointsAmount, ); - const [bonusPoints, setBonusPoints] = useState( - DEFAULT_RECHARGE_PRODUCT_FORM.bonusPoints, - ); const [badgeLabel, setBadgeLabel] = useState( DEFAULT_RECHARGE_PRODUCT_FORM.badgeLabel, ); @@ -141,8 +133,6 @@ export function AdminRechargeProductPage({ priceCents: parsePositiveIntegerOrZero(priceCents), kind: 'points', pointsAmount: parsePositiveIntegerOrZero(pointsAmount), - bonusPoints: parseNonNegativeIntegerOrZero(bonusPoints), - durationDays: 0, badgeLabel: badgeLabel.trim(), description: description.trim(), enabled, @@ -172,7 +162,6 @@ export function AdminRechargeProductPage({ setTitle(entry.title); setPriceCents(String(entry.priceCents)); setPointsAmount(String(entry.pointsAmount)); - setBonusPoints(String(entry.bonusPoints)); setBadgeLabel(entry.badgeLabel); setDescription(entry.description); setEnabled(entry.enabled); @@ -184,7 +173,6 @@ export function AdminRechargeProductPage({ setTitle(DEFAULT_RECHARGE_PRODUCT_FORM.title); setPriceCents(DEFAULT_RECHARGE_PRODUCT_FORM.priceCents); setPointsAmount(DEFAULT_RECHARGE_PRODUCT_FORM.pointsAmount); - setBonusPoints(DEFAULT_RECHARGE_PRODUCT_FORM.bonusPoints); setBadgeLabel(DEFAULT_RECHARGE_PRODUCT_FORM.badgeLabel); setDescription(DEFAULT_RECHARGE_PRODUCT_FORM.description); setEnabled(DEFAULT_RECHARGE_PRODUCT_FORM.enabled); @@ -248,26 +236,15 @@ export function AdminRechargeProductPage({ - - - setPointsAmount(event.target.value)} - /> - - - setBonusPoints(event.target.value)} - /> - - + + setPointsAmount(event.target.value)} + /> + @@ -397,7 +374,7 @@ function sortProducts(entries: ProfileRechargeProductConfigAdminResponse[]) { function formatProductContent( entry: ProfileRechargeProductConfigAdminResponse, ) { - return `${entry.pointsAmount} + ${entry.bonusPoints} 泥点`; + return `${entry.pointsAmount} 泥点`; } function formatPrice(priceCents: number) { diff --git a/scripts/admin-web-fake-api.mjs b/scripts/admin-web-fake-api.mjs index 3d10484b3..5d7025eac 100644 --- a/scripts/admin-web-fake-api.mjs +++ b/scripts/admin-web-fake-api.mjs @@ -800,21 +800,16 @@ function route(pathname, method, url, requestBody = '') { }; } if (pathname === '/admin/api/profile/recharge-products') { - const product = ( - productId, - title, - priceCents, - pointsAmount, - bonusPoints, - ) => ({ + const product = (productId, title, priceCents, pointsAmount) => ({ productId, title, priceCents, kind: 'points', pointsAmount, - bonusPoints, + // 已退役列:真实后端固定写 0,假数据也保持一致。 + bonusPoints: 0, durationDays: 0, - badgeLabel: bonusPoints ? `赠送 ${bonusPoints}` : '', + badgeLabel: '', description: `${pointsAmount} 泥点`, tier: 'none', membershipPeriodPoints: 0, @@ -830,8 +825,8 @@ function route(pathname, method, url, requestBody = '') { }); return { entries: [ - product('prod_points_600', '600 泥点', 3_000, 600, 0), - product('prod_points_1200', '1200 泥点 + 赠送 100', 6_000, 1_200, 100), + product('prod_points_600', '600 泥点', 3_000, 600), + product('prod_points_1200', '1200 泥点', 6_000, 1_200), ], }; } diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 6c90babbd..13ff54860 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1131,8 +1131,6 @@ pub async fn admin_upsert_profile_recharge_product( payload.price_cents, kind, payload.points_amount, - payload.bonus_points, - payload.duration_days, payload.badge_label.unwrap_or_default(), payload.description.unwrap_or_default(), payload.enabled, diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 0b6b6d8b5..458665486 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -393,8 +393,6 @@ pub fn build_runtime_profile_recharge_product_admin_upsert_input( price_cents: u64, kind: RuntimeProfileRechargeProductKind, points_amount: u64, - bonus_points: u64, - duration_days: u32, badge_label: String, description: String, enabled: bool, @@ -414,9 +412,6 @@ pub fn build_runtime_profile_recharge_product_admin_upsert_input( if points_amount == 0 { return Err(RuntimeProfileFieldError::InvalidRechargeProductPoints); } - if duration_days != 0 || bonus_points != 0 { - return Err(RuntimeProfileFieldError::InvalidRechargeProductFields); - } } // 中文注释:会员商品已迁移到 profile_membership_plan 目录,这里只保留泥点商品。 RuntimeProfileRechargeProductKind::Membership => { @@ -431,8 +426,6 @@ pub fn build_runtime_profile_recharge_product_admin_upsert_input( price_cents, kind, points_amount, - bonus_points, - duration_days, badge_label: normalize_optional_string(Some(badge_label)).unwrap_or_default(), description: normalize_optional_string(Some(description)).unwrap_or_default(), enabled, diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 3dbbbe694..bfbbe7837 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -2082,8 +2082,6 @@ pub struct RuntimeProfileRechargeProductAdminUpsertInput { pub price_cents: u64, pub kind: RuntimeProfileRechargeProductKind, pub points_amount: u64, - pub bonus_points: u64, - pub duration_days: u32, pub badge_label: String, pub description: String, pub enabled: bool, diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index c38e0d9d1..51b72df95 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -89,7 +89,6 @@ pub enum RuntimeProfileFieldError { InvalidRechargeProductPrice, InvalidRechargeProductPoints, InvalidRechargeProductKind, - InvalidRechargeProductFields, InvalidMembershipPlan, InvalidMembershipPlanOrder, MissingPaymentChannel, @@ -185,9 +184,6 @@ impl std::fmt::Display for RuntimeProfileFieldError { Self::InvalidRechargeProductKind => { f.write_str("充值商品类型无效:会员商品已迁移到会员档位目录") } - Self::InvalidRechargeProductFields => { - f.write_str("泥点商品不支持 duration_days 与 bonus_points 字段") - } Self::InvalidMembershipPlan => { f.write_str("会员档位配置无效:月价、年价、每期泥点与并发上限必须有效") } diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index d9b0b88b2..6de475871 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -820,8 +820,6 @@ pub struct AdminUpsertProfileRechargeProductRequest { pub price_cents: u64, pub kind: String, pub points_amount: u64, - pub bonus_points: u64, - pub duration_days: u32, #[serde(default)] pub badge_label: Option, #[serde(default)] diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs index 51d067c9d..8367c0c9f 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs @@ -580,8 +580,6 @@ impl From price_cents: input.price_cents, kind: map_runtime_profile_recharge_product_kind(input.kind), points_amount: input.points_amount, - bonus_points: input.bonus_points, - duration_days: input.duration_days, badge_label: input.badge_label, description: input.description, enabled: input.enabled, diff --git a/server-rs/crates/spacetime-client/src/active/runtime.rs b/server-rs/crates/spacetime-client/src/active/runtime.rs index dd1d32724..8f03c3294 100644 --- a/server-rs/crates/spacetime-client/src/active/runtime.rs +++ b/server-rs/crates/spacetime-client/src/active/runtime.rs @@ -1699,8 +1699,6 @@ impl SpacetimeClient { price_cents: u64, kind: module_runtime::RuntimeProfileRechargeProductKind, points_amount: u64, - bonus_points: u64, - duration_days: u32, badge_label: String, description: String, enabled: bool, @@ -1714,8 +1712,6 @@ impl SpacetimeClient { price_cents, kind, points_amount, - bonus_points, - duration_days, badge_label, description, enabled, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_upsert_input_type.rs index a3babfb24..1a95d58aa 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_upsert_input_type.rs @@ -15,8 +15,6 @@ pub struct RuntimeProfileRechargeProductAdminUpsertInput { pub price_cents: u64, pub kind: RuntimeProfileRechargeProductKind, pub points_amount: u64, - pub bonus_points: u64, - pub duration_days: u32, pub badge_label: String, pub description: String, pub enabled: bool, 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 50c720011..6c0850b16 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -8500,8 +8500,6 @@ fn upsert_profile_recharge_product_config_record( input.price_cents, input.kind, input.points_amount, - input.bonus_points, - input.duration_days, input.badge_label, input.description, input.enabled, @@ -8533,8 +8531,9 @@ fn upsert_profile_recharge_product_config_record( price_cents: validated_input.price_cents, kind: validated_input.kind, points_amount: validated_input.points_amount, - bonus_points: validated_input.bonus_points, - duration_days: validated_input.duration_days, + // 已退役列:充点商品固定「¥1 = 10 泥点、无首充赠送」,不再从后台入参接收。 + bonus_points: 0, + duration_days: 0, badge_label: validated_input.badge_label, description: validated_input.description, // 已退役列:不参与运行态,写固定默认值即可。 From 14c147722a2cef416318e0fa7bf3dfa7f6cbf50b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 21:51:42 +0800 Subject: [PATCH 175/232] =?UTF-8?q?fix(=E4=BC=9A=E5=91=98=E7=BB=93?= =?UTF-8?q?=E7=AE=97):=20cycle=5Findex=3D=3D0=20=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E8=A1=8C=E6=8C=89=20max(1)=20=E5=BD=92=E4=B8=80=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E8=AF=AF=E5=88=A4=E7=8A=B6=E6=80=81=E5=B7=B2?= =?UTF-8?q?=E5=8F=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - frozen_membership_cycle_matches 比较双方都按 max(1) 归一(评审第 20 条) - 存量 cycle_index 为 0 的会员行与投影的 Some(1) 快照现在判定一致,不再永久返回「会员状态已变化,请重新下单」 - 扩展 frozen_membership_cycle_identity_rejects_advanced_cycle 覆盖迁移行;已验证去掉 max(1) 时用例失败 --- .../spacetime-module/src/runtime/active/profile.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 6c0850b16..b41d69eb8 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -4578,6 +4578,16 @@ mod tests { &settlement(None, None), &row )); + // 中文注释:存量的 `cycle_index == 0` 迁移行与投影的 `max(1)` 归一值等价, + // 不能因为「原始行还是 0」把建单快照 `Some(1)` 判成账期已变化。 + let legacy_row = ProfileMembership { + cycle_index: 0, + ..row + }; + assert!(frozen_membership_cycle_matches( + &settlement(Some(1), Some(anchor + 28 * day)), + &legacy_row + )); } #[test] @@ -9553,7 +9563,9 @@ fn frozen_membership_cycle_matches( row: &ProfileMembership, ) -> bool { if let Some(cycle_index_before) = settlement.cycle_index_before { - if cycle_index_before != row.cycle_index { + // `cycle_index == 0` 是存量「水位未初始化」哨兵;只读投影统一 `max(1)` 归一到第 1 期。 + // 结算侧同样归一,否则迁移行会被永久判成「会员状态已变化」而无法结算(评审第 20 条)。 + if cycle_index_before.max(1) != row.cycle_index.max(1) { return false; } } From 264f214fe51f7f569c5fb0e1817c5076faed2739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 21:56:26 +0800 Subject: [PATCH 176/232] =?UTF-8?q?docs(=E4=BC=9A=E5=91=98=E8=AE=A1?= =?UTF-8?q?=E8=B4=B9):=20=E5=90=8C=E6=AD=A5=E8=AF=84=E5=AE=A1=E7=AC=AC=201?= =?UTF-8?q?3=E2=80=9320=20=E9=A1=B9=E7=9A=84=E5=A5=91=E7=BA=A6=E4=B8=8E?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端架构文档:充点商品写入口收敛为 9 个字段,退役列保留且写死 0;首充赠送统一为 0 - 技术设计 §2.5/§5.7:补 ProfileMembershipStatusToken 与 5 个 token 枚举的 ts-rs 生成说明 - 技术设计 §11.4:AgcModelResolveError 增 NoModelForTier,模型解析错误统一走 typed AppError - 决策记录:新增第 13–20 项收口条目,含影响范围与验证结论 --- .../shared-memory/decision-log.md | 11 +++++++++++ ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 19 +++++++++++++------ ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 6 +++--- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 767ef029a..b5f197325 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9683,3 +9683,14 @@ 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`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §5.8、本文件。 - 验证:`npm run spacetime:generate`;`cargo check -p api-server`;`cargo test -p api-server runtime_profile::tests` 41 passed;`cargo test -p module-runtime`、`cargo test -p spacetime-client`、`cargo test -p spacetime-module` 全通过;`npm run check:spacetime-schema`、`npm run check:encoding`、`git diff --check` 通过。 + +## 2026-10-03 会员计费评审第 13–20 项收口:契约枚举化、生成类型、计费与结算防御 + +- 背景:评审剩下一批 medium / low 的契约与防御性缺陷,用户逐项确认后一次性修复;每项单独提交,便于回溯。 +- 决策(契约枚举化,13/14/18):`shared-contracts` 新增 `ProfileMembershipStatusToken`(`normal` / `active`),`AdminProfileMembershipPayload` 的 `plan` / `status` / `cycle_kind` 改用 token 枚举;5 个会员 token 枚举补 `ts_rs::TS` + `ts(export, ...)`,前端 `packages/shared/src/contracts/runtime.ts` 与 `apps/admin-web/src/api/adminApiTypes.ts` 改为生成类型别名 / re-export,禁止手写联合类型;`AdminAgcModel.access` 由裸 `String` 改为 `ProfileMembershipModelAccessToken`,未知值反序列化即拒。 +- 决策(计费防御,15/16):`quote_runtime_profile_membership_upgrade` 显式拒绝价格 / 每期泥点 `<=`,堵住 0 元升级;`advance_beijing_months_clamped` 去掉 `expect`,超出 i64 微秒可表示范围(约 ±29 万年)时按方向饱和到 `i64::MAX` / `i64::MIN`,不改签名以免牵动 `membership_cycle_window` / `membership_expires_at`。 +- 决策(错误链 typed,17):`resolve_llm_router_client` 返回 `Result<_, AppError>`,`load_owner_llm_catalog` 的 `503 + MODEL_ACCESS_UNAVAILABLE` 与 `resolve_requested_for` 的 `MODEL_UNAVAILABLE` 不再被拍平成中文,Chat Completions 与 `/api/llm/models`、代理端点错误码对齐。 +- 决策(去掉退役入参,19):后台 upsert 请求 DTO 与 SpacetimeDB procedure 入参移除 `bonus_points` / `duration_days`,`profile_recharge_product_config` 退役列保留、写死 0;删除 `RuntimeProfileFieldError::InvalidRechargeProductFields`;后台表单去掉「首充赠送」,假数据同步。 +- 决策(结算归一,20):`frozen_membership_cycle_matches` 比较双方都取 `max(1)`,修复 `cycle_index == 0` 迁移行被永久判成「会员状态已变化」的问题;不新增写库副作用。 +- 影响范围:`shared-contracts/{runtime,admin}.rs`、`module-runtime/{domain,commands,errors}.rs` 与 `module-runtime/src/{membership/upgrade,membership/cycle,agc_model_access}.rs`、`spacetime-client/{active,active/mapper/runtime_profile,module_bindings}`、`spacetime-module/src/runtime/active/profile.rs`、`api-server/src/{runtime_profile,llm/mod,agc_models}.rs`、`packages/shared/src/contracts/runtime.ts`、`apps/admin-web/src/api/adminApiTypes.ts`、`apps/admin-web/src/pages/AdminRechargeProductPage.tsx`、`scripts/admin-web-fake-api.mjs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、本文件。 +- 验证:`cargo test -p api-server` 1206 passed;`-p module-runtime` / `-p spacetime-client` / `-p spacetime-module` 全通过;`npm run spacetime:generate`、`npm run check:spacetime-schema`(92 表)、`npm run check:generated-bindings`(117 文件)、`npm run admin-web:typecheck`、`npm run typecheck`、`npm run check:encoding`、`git diff --check` 通过。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index c4f3dfde1..6bf7be8bb 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -151,9 +151,11 @@ pub struct ProfileMembershipPlan { - 新增 `RuntimeProfileMembershipModelAccess { Basic, Full }`(仅新增字段,本期不执行)。 - 新增 `RuntimeProfileMembershipChangeKind { Purchase, Upgrade }`(订单快照用,替代字符串)。 - `shared-contracts` 新增 wire 侧 `ProfileMembershipPlanToken` / `ProfileMembershipCycleKindToken` / - `ProfileMembershipModelAccessToken` / `ProfileMembershipChangeKindToken`,把 DTO 里原来的裸 - `String`(后台 upsert 请求、订单变更快照、会员 / 档位 / 报价响应)收口为 typed enum; - 与模块枚举通过 `From` 双向映射,JSON wire 值仍是小写 token,未知 token 在反序列化阶段被拒。 + `ProfileMembershipStatusToken` / `ProfileMembershipModelAccessToken` / `ProfileMembershipChangeKindToken`, + 把 DTO 里原来的裸 `String`(后台 upsert 请求、后台会员详情、订单变更快照、会员 / 档位 / 报价响应) + 收口为 typed enum;与模块枚举通过 `From` 双向映射,JSON wire 值仍是小写 token,未知 token 在 + 反序列化阶段被拒。这 5 个 token 枚举统一 `#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]` + 导出到 `packages/shared/src/contracts/generated/`,前端只 re-export,不再手写字符串联合类型。 - **删除** `RuntimeProfileMembershipTier { Normal, Month, Season, Year, Starter, Basic, Pro, Ultimate }` 及其 `as_str()`(会员侧零存量,用户已授权破坏性变更)。 - `RuntimeProfileWalletLedgerSourceType` **末尾追加** `MembershipUpgradeGrant` @@ -429,6 +431,11 @@ flowchart TD `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`。 +- 会员 token(`profileMembershipPlanToken` / `profileMembershipCycleKindToken` / + `profileMembershipStatusToken` / `profileMembershipModelAccessToken` / + `profileMembershipChangeKindToken`)同样由 ts-rs 导出为字符串联合(5 个生成文件), + `packages/shared/src/contracts/runtime.ts` 与 `apps/admin-web/src/api/adminApiTypes.ts` + 改为生成类型别名 / re-export,删掉本地手写字面量。 - `u64` 字段加 `ts(type = "number")`:JSON 走数字,不用 `bigint`,避免序列化与前端类型分叉。 - `npm run check:generated-bindings` 以「重新生成并与工作区逐字节比较」做门禁,防止 Rust 改了而生成文件没重跑。 - 需要展示用派生字段(如 `totalPoints` 合计)时,在前端 `utils/mudPoints.ts` 里派生,不改 DTO。 @@ -610,12 +617,12 @@ flowchart TD ### 11.4 实现落点(2026-10-03 编码约定) - **`module-runtime`**: - - 新增 `agc_model_access.rs`:权限档 `AgcModelAccess`(`basic` / `full`,`Default = Basic`)、越权错误码常量 `MODEL_NOT_AVAILABLE_FOR_PLAN`、解析错误 `AgcModelResolveError::{Unavailable, NotAvailableForPlan}`,以及 `RuntimeProfileMembershipModelAccess → AgcModelAccess` 的映射。 + - 新增 `agc_model_access.rs`:权限档 `AgcModelAccess`(`basic` / `full`,`Default = Basic`)、越权错误码常量 `MODEL_NOT_AVAILABLE_FOR_PLAN`、解析错误 `AgcModelResolveError::{Unavailable, NoModelForTier, NotAvailableForPlan}`(`Unavailable` 与 `NoModelForTier` 复用 `MODEL_UNAVAILABLE` 码,分别映射 422 / 503),以及 `RuntimeProfileMembershipModelAccess → AgcModelAccess` 的映射。 - `AgcModel` 末位追加 `#[serde(default)] access: AgcModelAccess`(存量目录 JSON 缺字段即 `Basic`,失败开放);`AgcModelCatalog` 新增 `available_models_for(access)`、`default_model_id_for(access)`、`resolve_requested_for(requested, access)`。 - **默认模型语义**:优先取目录登记的 `default_model_id`;当它不在该档可用集合里时,回退到目录顺序里第一个可用项;该档一个可用模型都没有时返回 `Unavailable`。 - **`spacetime-module`**:`external_generation_job` 追加 btree 索引 `(owner_user_id, status)`(`by_external_generation_job_owner_status`);`claim_external_generation_jobs_tx` 按账号解析 `concurrent_job_limit` 并在认领时过滤;上限解析走 `profile_membership.plan` → `profile_membership_plan.concurrent_job_limit`,缺会员行 / 缺目录行失败关闭到 `Normal`(=1)。 -- **`api-server`**:新增 `llm/model_access.rs`,调专用只读 procedure `get_profile_agc_model_access_and_return`(只读会员账期投影 + 档位目录 `model_access`,不刷新、不写库)并映射到 `AgcModelAccess`;`GET /api/llm/models` 按档过滤,`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全部按档解析,越权返回 403 + 错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`。 -- **后台**:`AdminAgcModel` 与后台「AGC 模型」页新增 `access` 字段(`basic` / `full`,缺省 `basic`),GET 回读、PUT 写入;**不做**自动播种或代码内清单。 +- **`api-server`**:新增 `llm/model_access.rs`,调专用只读 procedure `get_profile_agc_model_access_and_return`(只读会员账期投影 + 档位目录 `model_access`,不刷新、不写库)并映射到 `AgcModelAccess`;`GET /api/llm/models` 按档过滤,`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全部按档解析,越权返回 403 + 错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`,该档无可用模型 / 所选模型不可用分别返回 503 / 422 + `MODEL_UNAVAILABLE`。`resolve_llm_router_client` 返回 `Result<_, AppError>`,会员目录 `503 + MODEL_ACCESS_UNAVAILABLE` 与上述模型解析错误都通过 typed `AppError` 透传,不再拍平成中文。 +- **后台**:`AdminAgcModel` 与后台「AGC 模型」页新增 `access` 字段(`basic` / `full`,缺省 `basic`),GET 回读、PUT 写入;`AdminAgcModel.access` 与 `AdminUpsertProfileMembershipPlanRequest.model_access` 一样用 `ProfileMembershipModelAccessToken`,未知 token 反序列化即拒;**不做**自动播种或代码内清单。 - **性能说明(2026-10-03 已落地)**:账号档位走专用只读 procedure `get_profile_agc_model_access_and_return`(`9e397fe9c`),不再触发 `get_profile_recharge_center` 的账期 / 免费点刷新;`api-server` 于 `b0dc94b46` 切到该读。认领按账号折叠并发查询见 `c3c3fc5a2`。缺会员行 / 已过期按 `Normal`,缺档位目录行失败关闭到 `Basic`。 - **列表契约(2026-10-03 修订)**:`shared-contracts` 新增 `llm_catalog.rs` 承载 `LlmModelsResponse` / `LlmModelSummary` / `LlmUnavailableModel` / `LlmModelUnavailableReason`(`plan_required` / `disabled` / `unknown`);`AgcAgentMode` / `AgcModelProtocol` 从 `module-runtime` 迁入 `shared-contracts` 并由 `module-runtime` re-export;整个目录 DTO 与枚举用 `#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]` 导出到 `packages/shared/src/contracts/generated/`,`revision` 标 `#[ts(as = "f64")]`,AGC 前端从 `@genarrative/shared` 根导入,删掉手写的 `ClientLlmModel`。 - **分桶来源**:`module-runtime` 新增 `AgcModelCatalog::unavailable_models_for(access)`(保持目录顺序,返回 `(&AgcModel, LlmModelUnavailableReason)`),`api-server` 只做 DTO 映射;`models` 仍走 `available_models_for`;`unavailableModels` 恒定下发(无内容时为空数组)。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 9cd812614..0e380688b 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -154,10 +154,10 @@ npm run check:server-rs-ddd ## 账户充值数据契约 1. `profile_recharge_product_config` 是泥点和会员商品配置真相源,默认商品只在表为空时由 SpacetimeDB 播种。`module-runtime` 中的默认商品 helper 只作为空库种子和兼容入口,不再作为运行期业务真相。 -2. 默认泥点商品固定为四档:`points_60 = 60 泥点 / 600 分`、`points_180 = 180 + 90 泥点 / 1800 分`、`points_300 = 300 + 150 泥点 / 3000 分`、`points_680 = 680 + 340 泥点 / 6800 分`。`points_60` 的首充赠送为 `0`;后三档首次购买分别赠送基础泥点的 `50%`。 -3. 后台通过 `/admin/api/profile/recharge-products` 读写充值商品配置;字段覆盖 `productId`、标题、商品类型、金额分、基础泥点、首充赠送泥点、会员天数、徽标、说明、会员层级、会员每周期限时泥点、周期天数、队列上限、折扣率、启用状态和排序。 +2. 默认泥点商品固定为四档:`points_60 = 60 泥点 / 600 分`、`points_180 = 180 泥点 / 1800 分`、`points_300 = 300 泥点 / 3000 分`、`points_680 = 680 泥点 / 6800 分`。所有档位的首充赠送一律为 `0`(已取消首充加赠)。 +3. 后台通过 `/admin/api/profile/recharge-products` 读写充点商品配置;写入口只接受 `productId`、标题、商品类型(仅 `points`)、金额分、基础泥点、徽标、说明、启用状态和排序。`bonus_points` / `duration_days` 以及旧的会员层级、会员每周期限时泥点、周期天数、队列上限、折扣率列都是退役列:表中保留以兼容存量 schema,回读固定为 `0`,不再进入 upsert 入参。 4. 充值中心、下单校验和支付确认入账都读取 `profile_recharge_product_config`。充值中心 BFF 还必须在 `mudPointBalance` 下发 `totalPoints`、`permanentPoints`、`limitedPoints`、`limitedExpiresAt`、`dailyFreePoints`、`dailyFreeResetPoints` 和 `dailyFreeResetsAt`;前端以该 read model 为真相源,不得自行用总额相减推算余额桶。当前版本公开 UI 只渲染不限时泥点和每日免费泥点,`limitedPoints` 与 `limitedExpiresAt` 仅保留给存量兼容和后端结算。历史订单保留下单时写入的商品标题、金额、渠道、状态和 provider transaction id,不随配置改动回写。 -5. 泥点首充资格按 `user_id + product_id` 的历史 `paid` 订单独立判断。某个档位已支付后,只隐藏该档位的首充赠送;其它未购买档位仍展示和结算首充赠送。 +5. 泥点首充资格按 `user_id + product_id` 的历史 `paid` 订单独立判断。首充赠送当前恒为 `0`,因此该判定不改变前台展示和结算金额;兼容分支保留,待后续彻底移除首充赠送概念时一并删除。 6. `hasPointsRecharged` 只保留为账号是否发生过任一泥点充值的兼容字段,不得驱动所有商品展示隐藏或结算金额计算。前端只渲染后端返回的商品快照。 7. 当前版本公开充值 UI 只展示泥点商品,不渲染会员购买页签、会员商品、购买会员或升级会员入口。充值中心响应中的会员商品兼容字段、默认会员商品、`profile_membership` 和周期刷新逻辑继续保留;存量会员的 `cycle_remaining_points` 仍通过充值中心 read model 下发用于兼容和结算,但不作为限时泥点在当前版本前台展示。 8. 默认会员商品为空库播种时使用 `Starter / Basic / Pro / Ultimate` 四档,默认有效期均为 30 天,每周期限时泥点分别为 `200 / 800 / 2500 / 6000`,队列上限分别为 `2 / 2 / 5 / 10`。 From 48b688095538186196aefd55f86f27be13958a65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 11:55:29 +0800 Subject: [PATCH 177/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=AF=BC=E8=88=AA):=20=E4=BC=9A=E5=91=98=E6=A1=A3=E4=BD=8D?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E8=A1=A5=E5=85=A5=E5=85=85=E5=80=BC=E5=88=86?= =?UTF-8?q?=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 membership-plans 加入 adminNavGroups 的 billing 分组,桌面端侧边栏不再漏掉「会员档位」入口 --- apps/admin-web/src/app/adminRoutes.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index b63eeec24..39f504faf 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -111,7 +111,12 @@ export const adminNavGroups: AdminNavGroupDefinition[] = [ { id: 'billing', label: '充值与支付', - routeIds: ['recharge-products', 'recharge-orders', 'payment-orders'], + routeIds: [ + 'recharge-products', + 'membership-plans', + 'recharge-orders', + 'payment-orders', + ], }, { id: 'agc', From 5a776f61cef690565217133fd9b23df92bd0f599 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 11:55:40 +0800 Subject: [PATCH 178/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E5=85=85=E5=80=BC?= =?UTF-8?q?=E5=95=86=E5=93=81):=20=E4=BF=9D=E5=AD=98=E5=89=8D=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E4=BB=B7=E6=A0=BC=E5=88=86=E4=B8=8E=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E6=B3=A5=E7=82=B9=E4=B8=BA=E6=AD=A3=E6=95=B4=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminRechargeProductPage 改用严格 parsePositiveInteger 解析价格分与基础泥点 - 空串/非法输入直接提示表单错误,不再静默落库成 0 价或 0 泥点商品 --- .../src/pages/AdminRechargeProductPage.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx index 84c2d714c..695bef442 100644 --- a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx @@ -19,7 +19,7 @@ import { } from '../api/adminApiClient'; import type { ProfileRechargeProductConfigAdminResponse } from '../api/adminApiTypes'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; -import { handlePageError, parsePositiveIntegerOrZero } from './pageUtils'; +import { handlePageError, parsePositiveInteger } from './pageUtils'; interface AdminRechargeProductPageProps { token: string; @@ -117,6 +117,13 @@ export function AdminRechargeProductPage({ } setErrorMessage(''); + // 中文注释:价格分与基础泥点必须是正整数;空串/非法输入不能静默落库成 0。 + const normalizedPriceCents = parsePositiveInteger(priceCents); + const normalizedPointsAmount = parsePositiveInteger(pointsAmount); + if (normalizedPriceCents === null || normalizedPointsAmount === null) { + setErrorMessage('价格分与基础泥点必须是大于 0 的整数'); + return; + } const confirmed = await confirmWrite({ action: enabled ? '保存充值商品' : '停用充值商品', target: productId.trim(), @@ -130,9 +137,9 @@ export function AdminRechargeProductPage({ const response = await upsertProfileRechargeProduct(token, { productId: productId.trim(), title: title.trim(), - priceCents: parsePositiveIntegerOrZero(priceCents), + priceCents: normalizedPriceCents, kind: 'points', - pointsAmount: parsePositiveIntegerOrZero(pointsAmount), + pointsAmount: normalizedPointsAmount, badgeLabel: badgeLabel.trim(), description: description.trim(), enabled, From 6e419d0d80e253d2d5d142e674440ffa1145bf91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 11:57:28 +0800 Subject: [PATCH 179/232] =?UTF-8?q?=E6=B8=85=E7=90=86(=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=B7=A5=E5=85=B7):=20=E5=88=A0=E9=99=A4=E6=97=A0=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E6=96=B9=E7=9A=84=20parseNonNegativeIntegerOrZero?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 该函数与 parsePositiveIntegerOrZero 行为一致且无任何调用点,删除死代码 --- apps/admin-web/src/pages/pageUtils.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/apps/admin-web/src/pages/pageUtils.ts b/apps/admin-web/src/pages/pageUtils.ts index 8a85eaceb..133305631 100644 --- a/apps/admin-web/src/pages/pageUtils.ts +++ b/apps/admin-web/src/pages/pageUtils.ts @@ -58,11 +58,3 @@ export function parseNonNegativeInteger(value: string): number | null { export function parsePositiveIntegerOrZero(value: string): number { return parsePositiveInteger(value) ?? 0; } - -/** 落库用宽松解析:非法输入按 `0` 处理,避免后台保存被输入抖动打断。 - * - * 与 [`parsePositiveIntegerOrZero`] 行为一致(`0` 与非法输入都落到 `0`),保留独立名字只为让调用点 - * 表达业务语义;真正的整数解析统一走 [`parseSafeIntegerText`]。 */ -export function parseNonNegativeIntegerOrZero(value: string): number { - return parseNonNegativeInteger(value) ?? 0; -} From 0a6c70a64ff5fea047abcb2cffac3a04b48343c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 11:57:39 +0800 Subject: [PATCH 180/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E6=A1=A3=E4=BD=8D):=20=E6=9C=AA=E7=9F=A5=E6=A1=A3=E4=BD=8D=20c?= =?UTF-8?q?ode=20=E5=9B=9E=E9=80=80=E5=B1=95=E7=A4=BA=E5=8E=9F=E5=A7=8B?= =?UTF-8?q?=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 列表与「最近保存」改用 formatMembershipPlan,未知 plan 不再渲染成空白 --- apps/admin-web/src/pages/AdminMembershipPlanPage.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx index 07c4ecea7..66885754d 100644 --- a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx +++ b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx @@ -22,7 +22,10 @@ import type { ProfileMembershipPlanAdminResponse, } from '../api/adminApiTypes'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; -import { membershipPlanLabels } from '../config/membershipDisplay'; +import { + formatMembershipPlan, + membershipPlanLabels, +} from '../config/membershipDisplay'; import { handlePageError, parsePositiveIntegerOrZero } from './pageUtils'; interface AdminMembershipPlanPageProps { @@ -394,7 +397,7 @@ export function AdminMembershipPlanPage({ type="button" onClick={() => fillForm(entry)} > - {entry.title || membershipPlanLabels[entry.plan]} + {entry.title || formatMembershipPlan(entry.plan)} {entry.plan} · rank {entry.rank} @@ -420,7 +423,7 @@ export function AdminMembershipPlanPage({ label: <>档位, value: ( <> - {membershipPlanLabels[result.plan]}({result.plan}) + {formatMembershipPlan(result.plan)}({result.plan}) ), }, From 589a2623a33d67c42615910332a3310fca21363b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 11:57:48 +0800 Subject: [PATCH 181/232] =?UTF-8?q?=E6=B8=85=E7=90=86(=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=81=87=E6=95=B0=E6=8D=AE):=20=E7=A7=BB=E9=99=A4=E5=85=85?= =?UTF-8?q?=E5=80=BC=E5=95=86=E5=93=81=E5=B7=B2=E9=80=80=E5=BD=B9=E7=9A=84?= =?UTF-8?q?=E4=BC=9A=E5=91=98=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProfileRechargeProductConfigAdminResponse 已无 tier/membershipPeriodPoints 等字段,假数据同步删除,避免掩盖前后端漂移 --- scripts/admin-web-fake-api.mjs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/scripts/admin-web-fake-api.mjs b/scripts/admin-web-fake-api.mjs index 2d45ec45a..b2355f662 100644 --- a/scripts/admin-web-fake-api.mjs +++ b/scripts/admin-web-fake-api.mjs @@ -811,11 +811,6 @@ function route(pathname, method, url, requestBody = '') { durationDays: 0, badgeLabel: '', description: `${pointsAmount} 泥点`, - tier: 'none', - membershipPeriodPoints: 0, - membershipPeriodDays: 0, - membershipQueueLimit: 0, - membershipDiscountBps: 0, enabled: true, sortOrder: 1, createdBy: 'u_admin', From ff2bd5b57a79196ad1799a822c5e95812bb6211d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 11:58:52 +0800 Subject: [PATCH 182/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=AF=A6=E6=83=85):=20=E4=BC=9A=E5=91=98=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=8C=89=E4=B8=AD=E6=96=87=E5=B1=95=E7=A4=BA=E5=90=8D=E6=B8=B2?= =?UTF-8?q?=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - membershipDisplay 新增 formatMembershipStatus(normal→非会员、active→生效中,未知值原样返回) - AdminUserDetailDialog 不再直接渲染后端 status token --- .../src/components/AdminUserDetailDialog.tsx | 3 ++- apps/admin-web/src/config/membershipDisplay.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.tsx index 2da40b420..c1822b965 100644 --- a/apps/admin-web/src/components/AdminUserDetailDialog.tsx +++ b/apps/admin-web/src/components/AdminUserDetailDialog.tsx @@ -29,6 +29,7 @@ import type { import { formatMembershipCycleKind, formatMembershipPlan, + formatMembershipStatus, } from '../config/membershipDisplay'; import { useAdminWriteConfirm } from './useAdminWriteConfirm'; @@ -541,7 +542,7 @@ function MembershipSection({ }) { const metrics: Array<[string, string]> = [ ['档位', `${formatMembershipPlan(membership.plan)}(${membership.plan})`], - ['状态', membership.status || '-'], + ['状态', formatMembershipStatus(membership.status) || '-'], [ '账期', `${formatMembershipCycleKind(membership.cycleKind)} 第 ${membership.cycleIndex}/${membership.cycleCount} 期`, diff --git a/apps/admin-web/src/config/membershipDisplay.ts b/apps/admin-web/src/config/membershipDisplay.ts index 7bf7b09e6..411f76dca 100644 --- a/apps/admin-web/src/config/membershipDisplay.ts +++ b/apps/admin-web/src/config/membershipDisplay.ts @@ -21,6 +21,12 @@ export const membershipCycleKindLabels: Record< yearly: '年付', }; +/** 会员状态展示名;与后端 `RuntimeProfileMembershipStatus` 的 `normal` / `active` 对齐。 */ +export const membershipStatusLabels: Record = { + normal: '非会员', + active: '生效中', +}; + /** 把后端的档位 code 映射成展示名,未知值原样返回。 */ export function formatMembershipPlan(plan: string): string { return membershipPlanLabels[plan as ProfileMembershipPlan] ?? plan; @@ -30,3 +36,8 @@ export function formatMembershipPlan(plan: string): string { export function formatMembershipCycleKind(kind: string): string { return membershipCycleKindLabels[kind as ProfileMembershipCycleKind] ?? kind; } + +/** 把后端的会员状态 code 映射成展示名,未知值原样返回。 */ +export function formatMembershipStatus(status: string): string { + return membershipStatusLabels[status] ?? status; +} From 5d2de88a400c5e53ee7bcf77b4943192396f0574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 11:59:12 +0800 Subject: [PATCH 183/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E5=85=85=E5=80=BC?= =?UTF-8?q?=E5=BC=B9=E5=B1=82):=20=E5=BD=93=E5=89=8D=E6=A1=A3=E4=BD=8D?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=E6=97=B6=E7=A6=81=E7=94=A8=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E4=B8=8B=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveMembershipPlanAction 在有效会员查不到当前档位时返回「套餐信息缺失」并禁用,避免误判为新客对所有档位开放购买 --- .../src/components/PlatformProfileRechargeModal/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index e8bd6abea..619bfae2e 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -392,9 +392,13 @@ function resolveMembershipPlanAction( ): MembershipPlanAction { const isActive = membership?.status === 'active' && membership.plan !== 'normal'; - if (!isActive || !membership || !currentPlan) { + if (!isActive || !membership) { return { disabled: false, label: '开通会员', mode: 'purchase' }; } + // 有效会员但目录里查不到当前档位:无法判定升降档,禁用下单而不是退化成「新购」。 + if (!currentPlan) { + return { disabled: true, label: '套餐信息缺失', mode: 'purchase' }; + } if (plan.plan === membership.plan) { return { disabled: true, label: '当前套餐', mode: 'purchase' }; } From f0dfddb910c0606b0fbbb02c0e0e26f22e3b9f00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 11:59:32 +0800 Subject: [PATCH 184/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E5=85=85=E5=80=BC?= =?UTF-8?q?=E5=BC=B9=E5=B1=82):=20=E6=9C=AA=E9=80=89=E4=B8=AD=E6=97=B6?= =?UTF-8?q?=E5=B7=A6=E6=96=B9=E5=90=91=E9=94=AE=E5=9B=9E=E5=8D=B7=E5=88=B0?= =?UTF-8?q?=E6=9C=80=E5=90=8E=E4=B8=80=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveRovingIndex 的 currentIndex 允许为 -1,ArrowLeft 特判为 length-1,避免落到倒数第二项 --- .../src/components/PlatformProfileRechargeModal/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index 619bfae2e..04ed1522c 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -54,7 +54,10 @@ function resolveRovingIndex( case 'End': return length - 1; case 'ArrowLeft': - return (currentIndex - 1 + length) % length; + // 未选中(-1)时应回卷到最后一项,而不是 (length - 2) % length。 + return currentIndex < 0 + ? length - 1 + : (currentIndex - 1 + length) % length; case 'ArrowRight': return (currentIndex + 1) % length; default: From d3f02d6bc99c44465a4a82c3607a57218a5dfc80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 11:59:47 +0800 Subject: [PATCH 185/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E5=85=85=E5=80=BC?= =?UTF-8?q?=E5=BC=B9=E5=B1=82):=20=E6=B3=A5=E7=82=B9=E6=A1=A3=E4=BD=8D?= =?UTF-8?q?=E7=A6=81=E7=94=A8=E8=8C=83=E5=9B=B4=E6=94=B6=E6=95=9B=E5=88=B0?= =?UTF-8?q?=E6=B3=A5=E7=82=B9=E5=95=86=E5=93=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - submittingProductId 现与会员下单共用,仅当提交的是泥点商品时才禁用整组泥点档位,避免会员提交期间跨页签耦合 --- .../src/components/PlatformProfileRechargeModal/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index 04ed1522c..42a0ee7b0 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -913,7 +913,9 @@ function PlatformPointProductGrid({ role="radio" aria-checked={selected} tabIndex={selected ? 0 : -1} - disabled={Boolean(submittingProductId)} + disabled={products.some( + (pointProduct) => pointProduct.productId === submittingProductId, + )} onClick={() => onSelect(product)} aria-label={`${formatMudPointCount(product.pointsAmount)}泥点 ${formatRechargePrice(product.priceCents)}`} className={joinClassNames( From 0c9a16ba1a7856a39aeea021d70e1c3b5f432616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 12:01:12 +0800 Subject: [PATCH 186/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(AGC=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B):=20=E6=9C=AA=E7=9F=A5=E6=9D=83=E9=99=90=E6=A1=A3?= =?UTF-8?q?=E5=8F=8D=E5=BA=8F=E5=88=97=E5=8C=96=E5=A4=B1=E8=B4=A5=E5=BC=80?= =?UTF-8?q?=E6=94=BE=E4=B8=BA=20basic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AgcModelAccess 将 Basic 放到最后一个变体并加 #[serde(other)],未知 access 不再让整份目录解析失败 - 补充未知 access 反序列化回退 Basic 的单测 --- .../module-runtime/src/agc_model_access.rs | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/server-rs/crates/module-runtime/src/agc_model_access.rs b/server-rs/crates/module-runtime/src/agc_model_access.rs index bf74ee07c..67c0109b7 100644 --- a/server-rs/crates/module-runtime/src/agc_model_access.rs +++ b/server-rs/crates/module-runtime/src/agc_model_access.rs @@ -23,13 +23,16 @@ pub const MODEL_NOT_AVAILABLE_FOR_PLAN: &str = "MODEL_NOT_AVAILABLE_FOR_PLAN"; /// 单个模型要求的权限档。 /// /// 缺省 `Basic`(失败开放):存量目录 JSON 缺该字段、后台未人工标注都按基础档处理, -/// 只有被后台明确标成 `Full` 的模型才会对 `Basic` 档账号产生拦截。 +/// 只有被后台明确标成 `Full` 的模型才会对 `Basic` 档账号产生拦截。未知取值同样 +/// 失败开放到 `Basic`(`#[serde(other)]`),避免将来新增档位时旧服务端整份目录解析失败、 +/// 静默回退到上游目录而丢掉人工标注。 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum AgcModelAccess { - #[default] - Basic, Full, + #[default] + #[serde(other)] + Basic, } impl AgcModelAccess { @@ -246,6 +249,17 @@ mod tests { assert_eq!(catalog.models[0].access, AgcModelAccess::Basic); } + #[test] + fn unknown_catalog_json_access_fails_open_to_basic() { + // 中文注释:未知 access 取值(例如将来新增档位)不能让整份目录解析失败、 + // 静默回退上游目录,应失败开放到 Basic。 + let catalog: AgcModelCatalog = serde_json::from_str( + r#"{"revision":1,"defaultModelId":"model-a","models":[{"id":"model-a","alias":"model-a","modelId":"model-a","enabled":true,"agentMode":"codex","access":"premium"}]}"#, + ) + .expect("未知 access 应失败开放到 Basic"); + assert_eq!(catalog.models[0].access, AgcModelAccess::Basic); + } + #[test] fn basic_plan_does_not_see_full_models_and_gets_first_basic_default() { let mut catalog = catalog_from_ids(&["model-a", "model-b", "model-c"], 1); From 8bc48e9e616a4194cbbe2271b3ae017711c9cf64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 12:01:32 +0800 Subject: [PATCH 187/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E8=B4=A6=E6=9C=9F):=20=E5=8C=97=E4=BA=AC=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E5=B9=B4=E4=BB=BD=E8=B6=8A=E7=95=8C=E5=85=88=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=E5=86=8D=E8=BF=9B=E5=8E=86=E6=B3=95=E6=8D=A2=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - beijing_local_micros 增加 ±292_278 年范围检查,避免 days_from_civil 内部 i32 乘法溢出 - 补充 10_000_000 年等溢出用例 --- server-rs/crates/module-runtime/src/membership/cycle.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server-rs/crates/module-runtime/src/membership/cycle.rs b/server-rs/crates/module-runtime/src/membership/cycle.rs index c0ba36070..2a8c39fb2 100644 --- a/server-rs/crates/module-runtime/src/membership/cycle.rs +++ b/server-rs/crates/module-runtime/src/membership/cycle.rs @@ -25,7 +25,10 @@ pub fn beijing_local_date_parts(micros: i64) -> (i32, u8, u8, i64) { /// 返回 `Option` 而不是用 `0` 兼作非法哨兵:北京 1970-01-01 08:00 本身就是合法的 `0`, /// 调用方必须能区分「非法日期」与「合法 epoch」。 pub fn beijing_local_micros(year: i32, month: u8, day: u8, time_of_day_micros: i64) -> Option { - if !(1..=12).contains(&month) + // 中文注释:先按 i64 微秒可表示范围(约 ±292_278 年)收口年份,避免 `days_from_civil` + // 内部的 `era * 146_097` 在 i32 上溢出(debug panic / release 回绕成看似合法的值)。 + if !(-292_278..=292_278).contains(&year) + || !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) || !(0..PROFILE_RUNTIME_DAY_MICROS).contains(&time_of_day_micros) @@ -155,6 +158,9 @@ mod tests { // 中文注释:超出 i64 微秒范围的年份必须返回 None,而不是饱和成看似合法的值。 assert_eq!(beijing_local_micros(1_000_000, 1, 1, 0), None); assert_eq!(beijing_local_micros(-1_000_000, 1, 1, 0), None); + // 这些年份会让 days_from_civil 内部的 i32 乘法溢出,必须在进入历法换算前收口。 + assert_eq!(beijing_local_micros(10_000_000, 1, 1, 0), None); + assert_eq!(beijing_local_micros(-10_000_000, 1, 1, 0), None); } #[test] From 6d9125586ae8dfddef35f5b1cf2aab18fcb39307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 12:01:43 +0800 Subject: [PATCH 188/232] =?UTF-8?q?=E6=96=87=E6=A1=A3(AGC=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B):=20access=20=E5=AD=97=E6=AE=B5=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E8=AE=BF=E9=97=AE=E7=BA=A7=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminAgcModel.access 与 AgcModel.access 的注释不再写成「会员档位」,避免与会员 plan 档位混淆 --- server-rs/crates/module-runtime/src/agc_models.rs | 2 +- server-rs/crates/shared-contracts/src/admin.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server-rs/crates/module-runtime/src/agc_models.rs b/server-rs/crates/module-runtime/src/agc_models.rs index 31479f3d8..4d3ebc735 100644 --- a/server-rs/crates/module-runtime/src/agc_models.rs +++ b/server-rs/crates/module-runtime/src/agc_models.rs @@ -32,7 +32,7 @@ pub struct AgcModel { /// 保存时必须与 `agent_mode` 组成合法组合。 #[serde(default)] pub protocol: Option, - /// 该模型要求的会员档位;缺省 `Basic`(失败开放),由人工在后台标 `Full`。 + /// 该模型要求的访问级别;缺省 `Basic`(失败开放),由人工在后台标 `Full`。 #[serde(default)] pub access: AgcModelAccess, } diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 96c1eebe6..4fb6dc499 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -1724,7 +1724,7 @@ pub struct AdminAgcModel { /// 服务端回读时始终给出解析后的具体值。 #[serde(default, skip_serializing_if = "Option::is_none")] pub protocol: Option, - /// 该模型要求的会员档位:`basic`(缺省,对全部档位开放)/ `full`(后台人工标注)。 + /// 该模型要求的访问级别:`basic`(缺省,对全部档位开放)/ `full`(后台人工标注)。 #[serde(default = "default_basic_agc_model_access")] pub access: ProfileMembershipModelAccessToken, } From 985b8718b8b189829c48997d06cd06c20449dcd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 12:14:20 +0800 Subject: [PATCH 189/232] =?UTF-8?q?=E6=B8=85=E7=90=86(=E6=B3=A5=E7=82=B9?= =?UTF-8?q?=E5=AE=9A=E4=BB=B7):=20=E7=A7=BB=E9=99=A4=E7=A1=AC=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E7=9A=84=E3=80=8C=C2=A51=20=3D=2010=20=E6=B3=A5?= =?UTF-8?q?=E7=82=B9=E3=80=8D=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 充值商品页描述去掉固定比例,不再把定价规则写死在 UI - 同步去掉公共充值弹层与测试里同口径的注释 --- apps/admin-web/src/pages/AdminRechargeProductPage.tsx | 4 ++-- .../components/PlatformProfileRechargeModal/index.test.tsx | 2 +- .../src/components/PlatformProfileRechargeModal/index.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx index 695bef442..4b6c3c0ea 100644 --- a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx @@ -44,7 +44,7 @@ const DEFAULT_RECHARGE_PRODUCT_FORM = { * 泥点充值商品。 * * 会员档位已迁到独立的「会员档位」页(`profile_membership_plan`),本页只维护充点商品。 - * 新方案固定「¥1 = 10 泥点、无首充赠送」,因此表单不再提供首充赠送 / 天数等已退役字段。 + * 表单不再提供首充赠送 / 天数等已退役字段。 */ export function AdminRechargeProductPage({ token, @@ -190,7 +190,7 @@ export function AdminRechargeProductPage({ buildPointProduct({ diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index 42a0ee7b0..62a948a47 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -1363,7 +1363,7 @@ export function PlatformProfileRechargeModal({ return null; } // 中文注释:默认预选第 3 档(索引 2,不足时退回末档),让首屏落在价格居中的档位; - // 充点商品目前同价(¥1 = 10 泥点),这里只影响默认选中项,不改变计费。 + // 充点商品目前同价,这里只影响默认选中项,不改变计费。 const defaultProductIndex = Math.min(2, pointProducts.length - 1); const fallbackProduct = pointProducts[defaultProductIndex]; return fallbackProduct ? fallbackProduct.productId : null; From af419e82c783ccc235a3406585eb2e0842b48b17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 12:14:53 +0800 Subject: [PATCH 190/232] =?UTF-8?q?=E6=96=87=E6=A1=A3(=E6=B3=A5=E7=82=B9?= =?UTF-8?q?=E9=92=B1=E5=8C=85):=20=E7=BB=9F=E4=B8=80=E4=B8=89=E6=B1=A0?= =?UTF-8?q?=E5=90=88=E8=AE=A1=E7=9A=84=E5=B1=95=E7=A4=BA=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E4=B8=BA=E5=8F=AF=E7=94=A8=E6=80=BB=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mudPoints 文档不再声明合计「不代表可用额度」,与钱包入口/充值弹层的「可用总额」文案对齐 --- packages/shared/src/utils/mudPoints.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/utils/mudPoints.ts b/packages/shared/src/utils/mudPoints.ts index 080bdfbd8..5e78425b0 100644 --- a/packages/shared/src/utils/mudPoints.ts +++ b/packages/shared/src/utils/mudPoints.ts @@ -3,12 +3,12 @@ import type { ProfileMudPointBalance } from '../contracts/runtime'; /** * 泥点余额的展示视图。 * - * 后端只并列暴露三个池子(每日免费 / 月度 / 永久),**不**提供「可用总额」字段, - * 以「合计」与「可用」的口径散回后端或各自相加都会分叉。需要合计展示时统一走 - * {@link resolveProfileMudPointBalanceView}。 + * 后端只并列暴露三个池子(每日免费 / 月度 / 永久),不直接下发合计字段;前端统一把三池 + * 相加后的值以「可用总额」展示。需要合计展示时统一走 + * {@link resolveProfileMudPointBalanceView},避免各调用点各自相加分叉。 */ export type ProfileMudPointBalanceView = ProfileMudPointBalance & { - /** 每日免费 + 月度 + 永久;只用于展示,不代表「可用额度」。 */ + /** 每日免费 + 月度 + 永久;前端统一以「可用总额」展示。 */ totalPoints: number; }; From 7579213beaf3c770ac48d138c0c4c6666df98361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 12:15:58 +0800 Subject: [PATCH 191/232] =?UTF-8?q?=E6=B8=85=E7=90=86(AGC=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B):=20=E5=88=A0=E9=99=A4=E6=97=A0=E6=A1=A3=E4=BD=8D?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E7=9A=84=E9=81=97=E7=95=99=20resolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 AgcModelCatalog::resolve / resolve_requested,统一走带会员档位的 resolve_requested_for - validate 内联默认项检查,测试改用 resolve_requested_for --- .../crates/module-runtime/src/agc_models.rs | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/server-rs/crates/module-runtime/src/agc_models.rs b/server-rs/crates/module-runtime/src/agc_models.rs index 4d3ebc735..b6aa1d710 100644 --- a/server-rs/crates/module-runtime/src/agc_models.rs +++ b/server-rs/crates/module-runtime/src/agc_models.rs @@ -144,26 +144,15 @@ impl AgcModelCatalog { )); } } - self.resolve(&self.default_model_id).map(|_| ()) - } - - pub fn resolve(&self, id: &str) -> Result<&str, String> { - self.models + // 默认项必须存在且启用,否则目录无法给出可用默认模型。 + if !self + .models .iter() - .find(|m| m.enabled && m.id == id) - .map(|m| m.model_id.as_str()) - .ok_or_else(|| "所选模型不可用,请刷新模型列表".into()) - } - - /// 请求侧解析:显式选择的标识按目录校验,未选择或占位标识使用默认项。 - pub fn resolve_requested(&self, requested: Option<&str>) -> Result<&str, String> { - let requested = requested - .map(str::trim) - .filter(|id| !id.is_empty() && *id != AGC_MODEL_PLATFORM_DEFAULT); - match requested { - Some(id) => self.resolve(id), - None => self.resolve(&self.default_model_id), + .any(|model| model.enabled && model.id == self.default_model_id) + { + return Err("所选模型不可用,请刷新模型列表".into()); } + Ok(()) } } @@ -316,18 +305,30 @@ mod tests { let mut catalog = AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap(); assert!(catalog.validate().is_ok()); - assert_eq!(catalog.resolve("model-a").unwrap(), "model-a"); + assert_eq!( + catalog.resolve_requested_for(Some("model-a"), AgcModelAccess::Basic), + Ok("model-a") + ); // 客户端不能直接指定实际模型名,只能回传目录标识。 - assert!(catalog.resolve("model-c").is_err()); - assert_eq!(catalog.resolve_requested(None).unwrap(), "model-a"); + assert_eq!( + catalog.resolve_requested_for(Some("model-c"), AgcModelAccess::Basic), + Err(crate::AgcModelResolveError::Unavailable) + ); + assert_eq!( + catalog.resolve_requested_for(None, AgcModelAccess::Basic), + Ok("model-a") + ); assert_eq!( catalog - .resolve_requested(Some(AGC_MODEL_PLATFORM_DEFAULT)) + .resolve_requested_for(Some(AGC_MODEL_PLATFORM_DEFAULT), AgcModelAccess::Basic) .unwrap(), "model-a" ); catalog.models[0].enabled = false; - assert!(catalog.resolve("model-a").is_err()); + assert_eq!( + catalog.resolve_requested_for(Some("model-a"), AgcModelAccess::Basic), + Err(crate::AgcModelResolveError::Unavailable) + ); assert!(catalog.validate().is_err()); } From c7150b590c6c1afb20e4567269908400a44dc66b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 12:18:05 +0800 Subject: [PATCH 192/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(LLM=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E5=A5=91=E7=BA=A6):=20protocol=20=E7=BC=BA=E7=9C=81?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=8C=89=20agent=5Fmode=20=E6=8E=A8=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LlmModelSummary/LlmUnavailableModel 反序列化缺失 protocol 时取 agent_mode 的默认协议,不再一律 openai_responses - 补 cc / anthropic 别名 / 显式 protocol 用例;同步生成绑定注释 --- .../contracts/generated/LlmModelSummary.ts | 2 +- .../generated/LlmUnavailableModel.ts | 6 +- .../shared-contracts/src/llm_catalog.rs | 103 +++++++++++++++++- 3 files changed, 106 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/contracts/generated/LlmModelSummary.ts b/packages/shared/src/contracts/generated/LlmModelSummary.ts index be2e3c30c..97c5e6ad6 100644 --- a/packages/shared/src/contracts/generated/LlmModelSummary.ts +++ b/packages/shared/src/contracts/generated/LlmModelSummary.ts @@ -11,6 +11,6 @@ export type LlmModelSummary = { displayName: string, id: string, */ agentMode: AgcAgentMode, /** - * 上游线协议;旧服务端缺字段时按 `openai_responses` 兼容。 + * 上游线协议;旧服务端缺字段时按 `agent_mode` 的默认协议兼容。 */ protocol: AgcModelProtocol, }; diff --git a/packages/shared/src/contracts/generated/LlmUnavailableModel.ts b/packages/shared/src/contracts/generated/LlmUnavailableModel.ts index f41401bbc..119e03dc7 100644 --- a/packages/shared/src/contracts/generated/LlmUnavailableModel.ts +++ b/packages/shared/src/contracts/generated/LlmUnavailableModel.ts @@ -6,4 +6,8 @@ import type { LlmModelUnavailableReason } from "./LlmModelUnavailableReason"; /** * 目录里存在、但当前账号不可用的一条模型。字段与 [`LlmModelSummary`] 对齐,另带原因。 */ -export type LlmUnavailableModel = { displayName: string, id: string, agentMode: AgcAgentMode, protocol: AgcModelProtocol, reason: LlmModelUnavailableReason, }; +export type LlmUnavailableModel = { displayName: string, id: string, agentMode: AgcAgentMode, +/** + * 上游线协议;旧服务端缺字段时按 `agent_mode` 的默认协议兼容。 + */ +protocol: AgcModelProtocol, reason: LlmModelUnavailableReason, }; diff --git a/server-rs/crates/shared-contracts/src/llm_catalog.rs b/server-rs/crates/shared-contracts/src/llm_catalog.rs index 72a79bfce..e25478082 100644 --- a/server-rs/crates/shared-contracts/src/llm_catalog.rs +++ b/server-rs/crates/shared-contracts/src/llm_catalog.rs @@ -124,9 +124,35 @@ impl AgcModelProtocol { } } +/// 反序列化中间态:`protocol` 缺失时按 `agent_mode` 的默认协议补齐(见 `From` 实现)。 +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct LlmModelSummaryWire { + display_name: String, + id: String, + #[serde(default)] + agent_mode: AgcAgentMode, + #[serde(default)] + protocol: Option, +} + +impl From for LlmModelSummary { + fn from(wire: LlmModelSummaryWire) -> Self { + let protocol = wire + .protocol + .unwrap_or_else(|| wire.agent_mode.default_protocol()); + Self { + display_name: wire.display_name, + id: wire.id, + agent_mode: wire.agent_mode, + protocol, + } + } +} + /// 本档可用的一条模型摘要。不包含上游真实模型名。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", from = "LlmModelSummaryWire")] #[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] #[cfg_attr( feature = "ts-bindings", @@ -144,14 +170,42 @@ pub struct LlmModelSummary { /// 执行器;旧服务端缺字段时按 `codex` 兼容。 #[serde(default)] pub agent_mode: AgcAgentMode, - /// 上游线协议;旧服务端缺字段时按 `openai_responses` 兼容。 + /// 上游线协议;旧服务端缺字段时按 `agent_mode` 的默认协议兼容。 #[serde(default)] pub protocol: AgcModelProtocol, } +/// 反序列化中间态:与 [`LlmModelSummaryWire`] 同规则,额外携带 `reason`。 +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct LlmUnavailableModelWire { + display_name: String, + id: String, + #[serde(default)] + agent_mode: AgcAgentMode, + #[serde(default)] + protocol: Option, + reason: LlmModelUnavailableReason, +} + +impl From for LlmUnavailableModel { + fn from(wire: LlmUnavailableModelWire) -> Self { + let protocol = wire + .protocol + .unwrap_or_else(|| wire.agent_mode.default_protocol()); + Self { + display_name: wire.display_name, + id: wire.id, + agent_mode: wire.agent_mode, + protocol, + reason: wire.reason, + } + } +} + /// 目录里存在、但当前账号不可用的一条模型。字段与 [`LlmModelSummary`] 对齐,另带原因。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", from = "LlmUnavailableModelWire")] #[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] #[cfg_attr( feature = "ts-bindings", @@ -168,6 +222,7 @@ pub struct LlmUnavailableModel { pub id: String, #[serde(default)] pub agent_mode: AgcAgentMode, + /// 上游线协议;旧服务端缺字段时按 `agent_mode` 的默认协议兼容。 #[serde(default)] pub protocol: AgcModelProtocol, pub reason: LlmModelUnavailableReason, @@ -224,3 +279,45 @@ pub struct LlmModelsResponse { #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub revision: u64, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_protocol_follows_agent_mode() { + let cc: LlmModelSummary = + serde_json::from_str(r#"{"displayName":"Claude","id":"model-a","agentMode":"cc"}"#) + .unwrap(); + assert_eq!(cc.protocol, AgcModelProtocol::Anthropic); + + let legacy_alias: LlmModelSummary = serde_json::from_str( + r#"{"displayName":"Claude","id":"model-a","agentMode":"anthropic"}"#, + ) + .unwrap(); + assert_eq!(legacy_alias.agent_mode, AgcAgentMode::Cc); + assert_eq!(legacy_alias.protocol, AgcModelProtocol::Anthropic); + + let codex: LlmModelSummary = + serde_json::from_str(r#"{"displayName":"GPT","id":"model-b"}"#).unwrap(); + assert_eq!(codex.agent_mode, AgcAgentMode::Codex); + assert_eq!(codex.protocol, AgcModelProtocol::OpenAiResponses); + + // 显式 protocol 优先,不被 agent_mode 覆盖。 + let explicit: LlmModelSummary = serde_json::from_str( + r#"{"displayName":"GPT","id":"model-c","agentMode":"codex","protocol":"openai_chat"}"#, + ) + .unwrap(); + assert_eq!(explicit.protocol, AgcModelProtocol::OpenAiChat); + } + + #[test] + fn unavailable_model_missing_protocol_follows_agent_mode() { + let cc: LlmUnavailableModel = serde_json::from_str( + r#"{"displayName":"Claude","id":"model-a","agentMode":"cc","reason":"disabled"}"#, + ) + .unwrap(); + assert_eq!(cc.protocol, AgcModelProtocol::Anthropic); + assert_eq!(cc.reason, LlmModelUnavailableReason::Disabled); + } +} From 4bd080eff580b1a6375305408f4c989e70516508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 12:19:15 +0800 Subject: [PATCH 193/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(LLM=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E5=A5=91=E7=BA=A6):=20=E5=93=8D=E5=BA=94=E6=9E=9A?= =?UTF-8?q?=E4=B8=BE=E6=9C=AA=E7=9F=A5=E5=8F=96=E5=80=BC=E5=89=8D=E5=90=91?= =?UTF-8?q?=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LlmModelsResponse 内 agentMode / protocol 遇到未知 token 回退 Codex / 由 agentMode 推导默认协议,不再让旧客户端整份反序列化失败 - 输入校验仍走严格 parse;补未知 token 用例 --- .../shared-contracts/src/llm_catalog.rs | 56 +++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/server-rs/crates/shared-contracts/src/llm_catalog.rs b/server-rs/crates/shared-contracts/src/llm_catalog.rs index e25478082..3b67537d5 100644 --- a/server-rs/crates/shared-contracts/src/llm_catalog.rs +++ b/server-rs/crates/shared-contracts/src/llm_catalog.rs @@ -124,15 +124,44 @@ impl AgcModelProtocol { } } +/// 响应 DTO 的前向兼容:未知执行器回退 `Codex`,避免后端新增取值时旧客户端整份 +/// [`LlmModelsResponse`] 反序列化失败。写入 / 输入校验仍走严格的 [`AgcAgentMode::parse`]。 +fn deserialize_forward_compat_agent_mode<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let raw = Option::::deserialize(deserializer)?; + Ok(match raw.as_deref() { + Some("cc") | Some("anthropic") => AgcAgentMode::Cc, + _ => AgcAgentMode::Codex, + }) +} + +/// 响应 DTO 的前向兼容:未知协议回退 `None`,由 `agent_mode` 的默认协议补齐。 +fn deserialize_forward_compat_protocol<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let raw = Option::::deserialize(deserializer)?; + Ok(match raw.as_deref() { + Some("openai_responses") => Some(AgcModelProtocol::OpenAiResponses), + Some("openai_chat") => Some(AgcModelProtocol::OpenAiChat), + Some("anthropic") => Some(AgcModelProtocol::Anthropic), + _ => None, + }) +} + /// 反序列化中间态:`protocol` 缺失时按 `agent_mode` 的默认协议补齐(见 `From` 实现)。 #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct LlmModelSummaryWire { display_name: String, id: String, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_forward_compat_agent_mode")] agent_mode: AgcAgentMode, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_forward_compat_protocol")] protocol: Option, } @@ -181,9 +210,9 @@ pub struct LlmModelSummary { struct LlmUnavailableModelWire { display_name: String, id: String, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_forward_compat_agent_mode")] agent_mode: AgcAgentMode, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_forward_compat_protocol")] protocol: Option, reason: LlmModelUnavailableReason, } @@ -320,4 +349,23 @@ mod tests { assert_eq!(cc.protocol, AgcModelProtocol::Anthropic); assert_eq!(cc.reason, LlmModelUnavailableReason::Disabled); } + + #[test] + fn unknown_enum_tokens_fall_back_for_forward_compat() { + // 后端将来新增执行器 / 协议时,旧客户端仍能解析整份响应,而不是 fail closed。 + let summary: LlmModelSummary = serde_json::from_str( + r#"{"displayName":"未来模型","id":"model-x","agentMode":"future-mode","protocol":"future-proto"}"#, + ) + .unwrap(); + assert_eq!(summary.agent_mode, AgcAgentMode::Codex); + assert_eq!(summary.protocol, AgcModelProtocol::OpenAiResponses); + + let unavailable: LlmUnavailableModel = serde_json::from_str( + r#"{"displayName":"未来模型","id":"model-x","agentMode":"future-mode","protocol":"future-proto","reason":"future-reason"}"#, + ) + .unwrap(); + assert_eq!(unavailable.agent_mode, AgcAgentMode::Codex); + assert_eq!(unavailable.protocol, AgcModelProtocol::OpenAiResponses); + assert_eq!(unavailable.reason, LlmModelUnavailableReason::Unknown); + } } From cc0c07223c8977b7cfec2f2ecaed534c727efb3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 12:22:28 +0800 Subject: [PATCH 194/232] =?UTF-8?q?=E9=87=8D=E6=9E=84(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E5=A5=91=E7=BA=A6):=20=E5=85=85=E5=80=BC=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E4=BC=9A=E5=91=98=E7=8A=B6=E6=80=81=E6=94=B9=E7=94=A8=E5=BC=BA?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProfileMembershipResponse.status 由裸 String 改为 ProfileMembershipStatusToken,与前端 ProfileMembershipStatus 对齐 - BFF 直接用 From 转换,测试改用具名枚举 --- server-rs/crates/api-server/src/runtime_profile.rs | 2 +- server-rs/crates/shared-contracts/src/runtime.rs | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 13ff54860..8e88dcfe9 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1914,7 +1914,7 @@ fn build_profile_recharge_center_response( ProfileRechargeCenterResponse { mud_point_balance, membership: ProfileMembershipResponse { - status: record.membership.status.as_str().to_string(), + status: record.membership.status.into(), plan: record.membership.plan.into(), started_at: record.membership.started_at, expires_at: record.membership.expires_at, diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 6de475871..be73898d7 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -433,7 +433,8 @@ pub struct ProfileMembershipPlanResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileMembershipResponse { - pub status: String, + /// 会员状态 token:`normal` / `active`。 + pub status: ProfileMembershipStatusToken, /// 当前档位 token;非会员与已到期都是 `normal`。 pub plan: ProfileMembershipPlanToken, pub started_at: Option, @@ -1819,7 +1820,7 @@ mod tests { permanent_points: 4, }, membership: ProfileMembershipResponse { - status: "active".to_string(), + status: ProfileMembershipStatusToken::Active, plan: ProfileMembershipPlanToken::Plus, started_at: Some("2026-04-25T10:00:00Z".to_string()), expires_at: Some("2026-05-25T10:00:00Z".to_string()), @@ -1929,7 +1930,7 @@ mod tests { let center = ProfileRechargeCenterResponse { mud_point_balance: empty_mud_point_balance(), membership: ProfileMembershipResponse { - status: "normal".to_string(), + status: ProfileMembershipStatusToken::Normal, plan: ProfileMembershipPlanToken::Normal, started_at: None, expires_at: None, @@ -2005,7 +2006,7 @@ mod tests { let center = ProfileRechargeCenterResponse { mud_point_balance: empty_mud_point_balance(), membership: ProfileMembershipResponse { - status: "normal".to_string(), + status: ProfileMembershipStatusToken::Normal, plan: ProfileMembershipPlanToken::Normal, started_at: None, expires_at: None, From 4f3faa04b51732a2d775c1aa574363d16d2dbed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 12:35:55 +0800 Subject: [PATCH 195/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=85=91=E6=8D=A2=E7=A0=81):=20=E7=AE=A1=E7=90=86=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=E8=A1=A5=E6=9C=BA=E5=99=A8=E5=8F=AF=E8=AF=BB=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E7=A0=81=E8=B5=B0=20400?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module-runtime 新增 RuntimeProfileRedeemCodeAdminErrorCode(not_found / invalid_field / allowed_users_required),后台兑换码增改与列表 procedure 结果末尾追加 error_code - module 侧用 ProfileRedeemCodeAdminProcedureError 在拒绝点赋码,字段校验统一 invalid_field、私有码缺用户 allowed_users_required - spacetime-client 后台 mapper 有码走 ProcedureRejected,api-server 由此返回 400 而不是 502 - 重新生成 SpacetimeDB 绑定;补 client 与 api-server 单测;同步设计文档 §5.8 与决策日志 --- .../shared-memory/decision-log.md | 9 ++ ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 20 ++- .../crates/api-server/src/runtime_profile.rs | 36 +++++- server-rs/crates/module-runtime/src/domain.rs | 41 +++++- .../crates/spacetime-client/src/active.rs | 2 +- .../src/active/mapper/runtime_profile.rs | 74 ++++++++++- .../spacetime-client/src/module_bindings.rs | 2 + ...ofile_redeem_code_admin_error_code_type.rs | 20 +++ ...m_code_admin_list_procedure_result_type.rs | 2 + ...redeem_code_admin_procedure_result_type.rs | 2 + .../src/runtime/active/profile.rs | 120 ++++++++++++++---- 11 files changed, 290 insertions(+), 38 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_error_code_type.rs diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2050e1cd3..65fabd8ff 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9719,3 +9719,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(结算归一,20):`frozen_membership_cycle_matches` 比较双方都取 `max(1)`,修复 `cycle_index == 0` 迁移行被永久判成「会员状态已变化」的问题;不新增写库副作用。 - 影响范围:`shared-contracts/{runtime,admin}.rs`、`module-runtime/{domain,commands,errors}.rs` 与 `module-runtime/src/{membership/upgrade,membership/cycle,agc_model_access}.rs`、`spacetime-client/{active,active/mapper/runtime_profile,module_bindings}`、`spacetime-module/src/runtime/active/profile.rs`、`api-server/src/{runtime_profile,llm/mod,agc_models}.rs`、`packages/shared/src/contracts/runtime.ts`、`apps/admin-web/src/api/adminApiTypes.ts`、`apps/admin-web/src/pages/AdminRechargeProductPage.tsx`、`scripts/admin-web-fake-api.mjs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、本文件。 - 验证:`cargo test -p api-server` 1206 passed;`-p module-runtime` / `-p spacetime-client` / `-p spacetime-module` 全通过;`npm run spacetime:generate`、`npm run check:spacetime-schema`(92 表)、`npm run check:generated-bindings`(117 文件)、`npm run admin-web:typecheck`、`npm run typecheck`、`npm run check:encoding`、`git diff --check` 通过。 + +## 2026-10-03 后台兑换码管理补机器可读错误码(typed error 收口) + +- 背景:`bd06e4269` 删除中文文案匹配后,`admin_disable_profile_redeem_code` / `admin_upsert_profile_redeem_code` / `admin_list_profile_redeem_codes` 仍只回传裸 `error_message`。这些 procedure 的 `ok = false` 是明确的业务拒绝(停用不存在的码、入参非法、私有码缺可兑换用户),却被 `spacetime-client` 拍平成 `Procedure(String)`,在 `api-server` 落 502,与会员 / 核销两条链路已落地的 typed error 不一致。 +- 决策(module 侧赋码):`module-runtime` 新增 `RuntimeProfileRedeemCodeAdminErrorCode`(`NotFound` / `InvalidField` / `AllowedUsersRequired`);`RuntimeProfileRedeemCodeAdminProcedureResult` 与 `RuntimeProfileRedeemCodeAdminListProcedureResult` 末尾追加 `error_code: Option<...>`;`spacetime-module` 用 `ProfileRedeemCodeAdminProcedureError::{Classified, Plain}` 在拒绝点显式赋码,`RuntimeProfileFieldError` 统一归 `invalid_field`,私有码缺可兑换用户归 `allowed_users_required`,停用不存在的码归 `not_found`。 +- 决策(客户端 / BFF):`RuntimeProfileProcedureRejectionCode` 增加 `RedeemCodeAdmin` 聚合变体,`spacetime-client` 两个后台 mapper 有 `error_code` 时走 `ProcedureRejected`、缺失时保持 `Procedure`;`api-server` 复用既有 `map_runtime_profile_client_error`,无需再引入字符串分类。 +- 决策(行为变化):后台兑换码的 `兑换码不存在`、入参校验失败、私有码缺可兑换用户由 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,module_bindings,module_bindings.rs}`、`server-rs/crates/api-server/src/runtime_profile.rs`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §5.8、本文件。 +- 验证:`npm run spacetime:generate`;`cargo check`(module-runtime / spacetime-module / spacetime-client / api-server);`cargo test -p spacetime-client` 30 passed;`cargo test -p api-server runtime_profile::tests` 42 passed;`cargo test -p module-runtime --lib` 113 passed;`npm run check:spacetime-schema`、`npm run check:encoding`、`git diff --check` 通过。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index 6bf7be8bb..e310e96e0 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -451,18 +451,24 @@ flowchart TD `RuntimeProfileMembershipUpgradeQuoteProcedureResult` / `RuntimeProfileRechargeCenterProcedureResult` / `RuntimeProfileMembershipPlanAdminProcedureResult` 末尾追加 `error_code: Option`(`error_message` 保留人读文案)。 -- 兑换码链路:`module-runtime` 的 `RuntimeProfileRewardCodeRedeemErrorCode`(`NotFound` / +- 兑换码核销链路:`module-runtime` 的 `RuntimeProfileRewardCodeRedeemErrorCode`(`NotFound` / `Disabled` / `NotStarted` / `Expired` / `UsesExhausted` / `NotAllowedForUser` / `InvalidReward`)在拒绝点显式产出,`RuntimeProfileRewardCodeRedeemProcedureResult` 末尾同样追加 `error_code`。 +- 后台兑换码管理链路:`module-runtime` 的 `RuntimeProfileRedeemCodeAdminErrorCode`(`NotFound` / + `InvalidField` / `AllowedUsersRequired`)在拒绝点显式产出, + `RuntimeProfileRedeemCodeAdminProcedureResult` / `RuntimeProfileRedeemCodeAdminListProcedureResult` + 末尾追加 `error_code`;字段校验错误统一归 `invalid_field`,私有码缺可兑换用户归 + `allowed_users_required`,停用不存在的码归 `not_found`。 - `spacetime-client` 的 `SpacetimeClientError::ProcedureRejected { code, message }` 用 - `RuntimeProfileProcedureRejectionCode` 聚合两条链路的 typed code;`api-server` 的 `runtime_profile` + `RuntimeProfileProcedureRejectionCode` 聚合三条链路的 typed code;`api-server` 的 `runtime_profile` 映射直接读变体字段返回 400(`provider = runtime-profile`),`AppError.code` 取 - `code.as_str()`,基础设施错误保持 502。字段校验类错误(如 user_id 缺失)不产出 `error_code`, - 仍按字符串错误落到 502。 -- 行为变化:`会员目录缺少档位 ...`、「已迁移的会员商品」以及兑换码的 `兑换码不存在` / `已停用` / - `未生效` / `已过期` / `次数已用完` / `不适用于当前账号` / `奖励无效` 原先落到 502,现在有明确 - 错误码,统一 400。`api-server` 已删除 `is_runtime_profile_redeem_code_domain_error` 文案匹配函数。 + `code.as_str()`,基础设施错误保持 502。会员 / 核销链路里未列出的字段校验类错误(如 user_id + 缺失)不产出 `error_code`,仍按字符串错误落到 502。 +- 行为变化:`会员目录缺少档位 ...`、「已迁移的会员商品」、兑换码核销的 `兑换码不存在` / + `已停用` / `未生效` / `已过期` / `次数已用完` / `不适用于当前账号` / `奖励无效`,以及后台兑换码 + 管理的 `兑换码不存在` / 入参校验失败 / 私有码缺可兑换用户原先落到 502,现在有明确错误码,统一 + 400。`api-server` 已删除 `is_runtime_profile_redeem_code_domain_error` 文案匹配函数。 - 兼容边界:procedure result 是 wire 结构,新增字段要求 module 与 api-server 同版本部署; 旧 module 不返回 `error_code` 时,新 client 绑定反序列化会失败。 diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 8e88dcfe9..981272697 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -2490,8 +2490,8 @@ mod tests { RuntimeProfileMembershipRecord, RuntimeProfileMembershipStatus, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductKind, - RuntimeProfileRechargeProductRecord, RuntimeProfileRewardCodeRedeemErrorCode, - RuntimeProfileWalletLedgerSourceType, + RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeAdminErrorCode, + RuntimeProfileRewardCodeRedeemErrorCode, RuntimeProfileWalletLedgerSourceType, }; use super::{ @@ -2574,6 +2574,38 @@ mod tests { assert_eq!(legacy_string_error.status_code(), StatusCode::BAD_GATEWAY); } + #[test] + fn redeem_code_admin_rejections_map_to_bad_request() { + // 后台兑换码管理的业务拒绝同样随 procedure 透传 error_code;「停用不存在的码」这类 + // 拒绝必须落 400,不能因为缺少机器码被当成 SpacetimeDB 基础设施故障落 502。 + for (code, expected_code, message) in [ + ( + RuntimeProfileRedeemCodeAdminErrorCode::NotFound, + "not_found", + "兑换码不存在", + ), + ( + RuntimeProfileRedeemCodeAdminErrorCode::InvalidField, + "invalid_field", + "兑换码奖励点数必须大于 0", + ), + ( + RuntimeProfileRedeemCodeAdminErrorCode::AllowedUsersRequired, + "allowed_users_required", + "私有兑换码必须指定可兑换用户", + ), + ] { + let error = map_runtime_profile_client_error(SpacetimeClientError::ProcedureRejected { + code: code.into(), + message: message.to_string(), + }); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST, "{message}"); + assert_eq!(error.code(), expected_code, "{message}"); + assert_eq!(error.message(), message); + } + } + #[test] fn membership_business_rejections_map_to_bad_request() { // 中文注释:会员业务拒绝由 procedure 透传机器可读 error_code,统一按 400 返回; diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index bfbbe7837..e09213f5b 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1567,13 +1567,41 @@ impl RuntimeProfileRewardCodeRedeemErrorCode { } } -/// profile procedure 业务拒绝的统一错误码:把会员与兑换码两条链路的 typed code 收在一处。 +/// 后台兑换码管理的机器可读拒绝码。 +/// +/// 与 [`RuntimeProfileRewardCodeRedeemErrorCode`] 同样的目的:后台兑换码的增改 / 停用拒绝 +/// 不能靠中文文案分类 HTTP 状态,每个拒绝点显式产出本枚举并随 procedure 结果透传。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeProfileRedeemCodeAdminErrorCode { + /// 兑换码在目录里不存在。 + NotFound, + /// 兑换码管理入参不合法(缺码、奖励为 0、次数为 0、有效期倒挂等)。 + InvalidField, + /// 私有兑换码没有可用于兑换的用户。 + AllowedUsersRequired, +} + +impl RuntimeProfileRedeemCodeAdminErrorCode { + pub fn as_str(&self) -> &'static str { + match self { + Self::NotFound => "not_found", + Self::InvalidField => "invalid_field", + Self::AllowedUsersRequired => "allowed_users_required", + } + } +} + +/// profile procedure 业务拒绝的统一错误码:把会员、兑换码核销与后台兑换码管理的 typed code +/// 收在一处。 /// /// 只在 `spacetime-client` 进程内传递,不落表、不过 wire,因此不派生 `SpacetimeType`。 #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RuntimeProfileProcedureRejectionCode { Membership(RuntimeProfileMembershipErrorCode), RewardCodeRedeem(RuntimeProfileRewardCodeRedeemErrorCode), + RedeemCodeAdmin(RuntimeProfileRedeemCodeAdminErrorCode), } impl RuntimeProfileProcedureRejectionCode { @@ -1581,6 +1609,7 @@ impl RuntimeProfileProcedureRejectionCode { match self { Self::Membership(code) => code.as_str(), Self::RewardCodeRedeem(code) => code.as_str(), + Self::RedeemCodeAdmin(code) => code.as_str(), } } } @@ -1597,6 +1626,12 @@ impl From for RuntimeProfileProcedureRe } } +impl From for RuntimeProfileProcedureRejectionCode { + fn from(code: RuntimeProfileRedeemCodeAdminErrorCode) -> Self { + Self::RedeemCodeAdmin(code) + } +} + /// 会员领域错误:机器可读 `code` + 人读 `message`。 /// /// 只在模块内部与 procedure 结果装载之间传递,不落 SpacetimeDB 表,因此不派生 `SpacetimeType`。 @@ -2708,6 +2743,8 @@ pub struct RuntimeProfileRedeemCodeAdminProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + /// 后台管理业务拒绝的机器可读错误码;非业务错误(入参、系统等)为 `None`。 + pub error_code: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -2717,6 +2754,8 @@ pub struct RuntimeProfileRedeemCodeAdminListProcedureResult { pub entries: Vec, pub operations: Vec, pub error_message: Option, + /// 后台管理业务拒绝的机器可读错误码;非业务错误(入参、系统等)为 `None`。 + pub error_code: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index fd3d19a0d..ad74ec262 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -235,7 +235,7 @@ pub enum SpacetimeClientError { Build(String), ConnectDropped, Procedure(String), - /// profile procedure 返回了带机器可读错误码的业务拒绝(会员 / 兑换码核销)。 + /// profile procedure 返回了带机器可读错误码的业务拒绝(会员 / 兑换码核销 / 后台兑换码管理)。 ProcedureRejected { code: module_runtime::RuntimeProfileProcedureRejectionCode, message: String, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs index 8367c0c9f..58e7fe632 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs @@ -1268,7 +1268,13 @@ pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result( result: RuntimeProfileRedeemCodeAdminProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); + return Err(match result.error_code { + Some(code) => SpacetimeClientError::procedure_rejected( + map_runtime_profile_redeem_code_admin_error_code_back(code), + result.error_message, + ), + None => SpacetimeClientError::procedure_failed(result.error_message), + }); } let snapshot = result @@ -1284,7 +1290,13 @@ pub(crate) fn map_runtime_profile_redeem_code_admin_list_procedure_result( result: RuntimeProfileRedeemCodeAdminListProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); + return Err(match result.error_code { + Some(code) => SpacetimeClientError::procedure_rejected( + map_runtime_profile_redeem_code_admin_error_code_back(code), + result.error_message, + ), + None => SpacetimeClientError::procedure_failed(result.error_message), + }); } Ok(RuntimeProfileRedeemCodeAdminListRecord { @@ -2141,6 +2153,22 @@ pub(crate) fn map_runtime_profile_reward_code_redeem_error_code_back( } } +pub(crate) fn map_runtime_profile_redeem_code_admin_error_code_back( + value: crate::module_bindings::RuntimeProfileRedeemCodeAdminErrorCode, +) -> module_runtime::RuntimeProfileRedeemCodeAdminErrorCode { + match value { + crate::module_bindings::RuntimeProfileRedeemCodeAdminErrorCode::NotFound => { + module_runtime::RuntimeProfileRedeemCodeAdminErrorCode::NotFound + } + crate::module_bindings::RuntimeProfileRedeemCodeAdminErrorCode::InvalidField => { + module_runtime::RuntimeProfileRedeemCodeAdminErrorCode::InvalidField + } + crate::module_bindings::RuntimeProfileRedeemCodeAdminErrorCode::AllowedUsersRequired => { + module_runtime::RuntimeProfileRedeemCodeAdminErrorCode::AllowedUsersRequired + } + } +} + pub(crate) fn map_runtime_profile_membership_model_access( value: module_runtime::RuntimeProfileMembershipModelAccess, ) -> crate::module_bindings::RuntimeProfileMembershipModelAccess { @@ -2484,3 +2512,45 @@ pub(crate) fn map_runtime_profile_feedback_status_back( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn admin_redeem_code_rejection_uses_typed_code() { + let result = RuntimeProfileRedeemCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some("兑换码不存在".to_string()), + error_code: Some( + crate::module_bindings::RuntimeProfileRedeemCodeAdminErrorCode::NotFound, + ), + }; + + match map_runtime_profile_redeem_code_admin_procedure_result(result) { + Err(SpacetimeClientError::ProcedureRejected { code, message }) => { + assert_eq!(code.as_str(), "not_found"); + assert_eq!(message, "兑换码不存在"); + } + other => panic!("expected ProcedureRejected, got {other:?}"), + } + } + + #[test] + fn admin_redeem_code_without_error_code_stays_plain_procedure_error() { + let result = RuntimeProfileRedeemCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some("SpacetimeDB 事务失败".to_string()), + error_code: None, + }; + + match map_runtime_profile_redeem_code_admin_procedure_result(result) { + Err(SpacetimeClientError::Procedure(message)) => { + assert_eq!(message, "SpacetimeDB 事务失败"); + } + other => panic!("expected Procedure, got {other:?}"), + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index e58aa6eb5..e7d05b5ab 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -783,6 +783,7 @@ pub mod runtime_profile_recharge_refund_recovery_status_type; pub mod runtime_profile_recharge_refund_snapshot_type; pub mod runtime_profile_recharge_refund_status_type; pub mod runtime_profile_redeem_code_admin_disable_input_type; +pub mod runtime_profile_redeem_code_admin_error_code_type; pub mod runtime_profile_redeem_code_admin_list_input_type; pub mod runtime_profile_redeem_code_admin_list_procedure_result_type; pub mod runtime_profile_redeem_code_admin_procedure_result_type; @@ -1669,6 +1670,7 @@ pub use runtime_profile_recharge_refund_recovery_status_type::RuntimeProfileRech pub use runtime_profile_recharge_refund_snapshot_type::RuntimeProfileRechargeRefundSnapshot; pub use runtime_profile_recharge_refund_status_type::RuntimeProfileRechargeRefundStatus; pub use runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput; +pub use runtime_profile_redeem_code_admin_error_code_type::RuntimeProfileRedeemCodeAdminErrorCode; pub use runtime_profile_redeem_code_admin_list_input_type::RuntimeProfileRedeemCodeAdminListInput; pub use runtime_profile_redeem_code_admin_list_procedure_result_type::RuntimeProfileRedeemCodeAdminListProcedureResult; pub use runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_error_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_error_code_type.rs new file mode 100644 index 000000000..3a5e6fa15 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_error_code_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileRedeemCodeAdminErrorCode { + NotFound, + + InvalidField, + + AllowedUsersRequired, +} + +impl __sdk::InModule for RuntimeProfileRedeemCodeAdminErrorCode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_list_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_list_procedure_result_type.rs index 86f26def6..5515525bd 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_list_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_list_procedure_result_type.rs @@ -5,6 +5,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; use super::runtime_profile_code_operation_snapshot_type::RuntimeProfileCodeOperationSnapshot; +use super::runtime_profile_redeem_code_admin_error_code_type::RuntimeProfileRedeemCodeAdminErrorCode; use super::runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -14,6 +15,7 @@ pub struct RuntimeProfileRedeemCodeAdminListProcedureResult { pub entries: Vec, pub operations: Vec, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileRedeemCodeAdminListProcedureResult { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs index 62254ff97..ca771ad9a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs @@ -4,6 +4,7 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_redeem_code_admin_error_code_type::RuntimeProfileRedeemCodeAdminErrorCode; use super::runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -12,6 +13,7 @@ pub struct RuntimeProfileRedeemCodeAdminProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileRedeemCodeAdminProcedureResult { 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 b41d69eb8..c240523c1 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -126,6 +126,53 @@ fn reward_code_redeem_error_from_field_error( } } +/// 后台兑换码管理 procedure 的错误载体。 +/// +/// 业务拒绝携带机器可读 [`RuntimeProfileRedeemCodeAdminErrorCode`];入参、系统等其它错误保持 +/// 普通字符串,透传时 `error_code` 为 `null`(与会员 / 核销链路同一约定)。 +enum ProfileRedeemCodeAdminProcedureError { + Classified(RuntimeProfileRedeemCodeAdminErrorCode, String), + Plain(String), +} + +impl From for ProfileRedeemCodeAdminProcedureError { + fn from(message: String) -> Self { + Self::Plain(message) + } +} + +impl From<&str> for ProfileRedeemCodeAdminProcedureError { + fn from(message: &str) -> Self { + Self::Plain(message.to_string()) + } +} + +impl ProfileRedeemCodeAdminProcedureError { + fn classified( + code: RuntimeProfileRedeemCodeAdminErrorCode, + message: impl Into, + ) -> Self { + Self::Classified(code, message.into()) + } + + fn into_parts(self) -> (String, Option) { + match self { + Self::Classified(code, message) => (message, Some(code)), + Self::Plain(message) => (message, None), + } + } +} + +/// 后台兑换码的字段校验错误统一归入 `invalid_field`,保证入参拒绝也落 400 而不是 502。 +fn redeem_code_admin_error_from_field_error( + error: RuntimeProfileFieldError, +) -> ProfileRedeemCodeAdminProcedureError { + ProfileRedeemCodeAdminProcedureError::classified( + RuntimeProfileRedeemCodeAdminErrorCode::InvalidField, + error.to_string(), + ) +} + #[spacetimedb::table(accessor = profile_dashboard_state)] pub struct ProfileDashboardState { #[primary_key] @@ -2697,12 +2744,17 @@ pub fn admin_upsert_profile_redeem_code( ok: true, record: Some(record), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRedeemCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + error_code, + } + } } } @@ -2716,12 +2768,17 @@ pub fn admin_disable_profile_redeem_code( ok: true, record: Some(record), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRedeemCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + error_code, + } + } } } @@ -2736,13 +2793,18 @@ pub fn admin_list_profile_redeem_codes( entries, operations, error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRedeemCodeAdminListProcedureResult { - ok: false, - entries: Vec::new(), - operations: Vec::new(), - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRedeemCodeAdminListProcedureResult { + ok: false, + entries: Vec::new(), + operations: Vec::new(), + error_message: Some(message), + error_code, + } + } } } @@ -7742,7 +7804,7 @@ fn redeem_profile_reward_code_record( fn admin_upsert_profile_redeem_code_record( ctx: &ReducerContext, input: RuntimeProfileRedeemCodeAdminUpsertInput, -) -> Result { +) -> Result { let validated_input = build_runtime_profile_redeem_code_admin_upsert_input( input.admin_user_id, input.code, @@ -7756,7 +7818,7 @@ fn admin_upsert_profile_redeem_code_record( input.expires_at_micros, input.updated_at_micros, ) - .map_err(|error| error.to_string())?; + .map_err(redeem_code_admin_error_from_field_error)?; let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); let allowed_user_ids = resolve_profile_redeem_code_allowed_user_ids(ctx, &validated_input)?; let existing = ctx @@ -7817,20 +7879,25 @@ fn admin_upsert_profile_redeem_code_record( fn admin_disable_profile_redeem_code_record( ctx: &ReducerContext, input: RuntimeProfileRedeemCodeAdminDisableInput, -) -> Result { +) -> Result { let validated_input = build_runtime_profile_redeem_code_admin_disable_input( input.admin_user_id, input.code, input.updated_at_micros, ) - .map_err(|error| error.to_string())?; + .map_err(redeem_code_admin_error_from_field_error)?; let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); let existing = ctx .db .profile_redeem_code() .code() .find(&validated_input.code) - .ok_or_else(|| "兑换码不存在".to_string())?; + .ok_or_else(|| { + ProfileRedeemCodeAdminProcedureError::classified( + RuntimeProfileRedeemCodeAdminErrorCode::NotFound, + "兑换码不存在", + ) + })?; ctx.db.profile_redeem_code().code().delete(&existing.code); let inserted = ctx.db.profile_redeem_code().insert(ProfileRedeemCode { @@ -8450,10 +8517,10 @@ fn admin_list_profile_redeem_code_records( Vec, Vec, ), - String, + ProfileRedeemCodeAdminProcedureError, > { let _validated_input = build_runtime_profile_redeem_code_admin_list_input(input.admin_user_id) - .map_err(|error| error.to_string())?; + .map_err(redeem_code_admin_error_from_field_error)?; let mut entries = ctx .db @@ -12151,7 +12218,7 @@ fn count_profile_redeem_code_user_usage(ctx: &ReducerContext, code: &str, user_i fn resolve_profile_redeem_code_allowed_user_ids( ctx: &ReducerContext, input: &RuntimeProfileRedeemCodeAdminUpsertInput, -) -> Result, String> { +) -> Result, ProfileRedeemCodeAdminProcedureError> { if input.mode != RuntimeProfileRedeemCodeMode::Private { return Ok(Vec::new()); } @@ -12172,7 +12239,10 @@ fn resolve_profile_redeem_code_allowed_user_ids( allowed_user_ids.dedup(); if allowed_user_ids.is_empty() { - return Err("私有兑换码必须指定可兑换用户".to_string()); + return Err(ProfileRedeemCodeAdminProcedureError::classified( + RuntimeProfileRedeemCodeAdminErrorCode::AllowedUsersRequired, + "私有兑换码必须指定可兑换用户", + )); } Ok(allowed_user_ids) From 5dc0654426eaf8eda7dbb7006bf8f9273a5aa8de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 13:50:16 +0800 Subject: [PATCH 196/232] =?UTF-8?q?=E9=87=8D=E6=9E=84(AGC=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=96=87=E6=A1=88):=20=E5=90=88=E5=B9=B6=E5=8E=9F?= =?UTF-8?q?=E5=9B=A0=E5=88=B0=E6=96=87=E6=A1=88=E7=9A=84=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 两个 helper 原先各维护一份 reason switch,新增 reason 时容易漏改一处 - 抽成单个 REASON_COPY 表,unknown 作为未知/缺失原因的兜底,两个 helper 共用 --- .../modelAvailabilityCopy.ts | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/modelAvailabilityCopy.ts b/apps/ai-game-creator-shell/src/features/project-workspace/modelAvailabilityCopy.ts index c37cdd28a..b8135de01 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/modelAvailabilityCopy.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/modelAvailabilityCopy.ts @@ -1,21 +1,34 @@ import type { ClientLlmModelUnavailableReason } from '../../services/llmModelCatalog'; /** - * 锁定项 hover / 键盘 focus 的原因文案。 + * 原因 → 文案的统一映射。 * - * `unknown` 是契约的前向兜底,后端将来新增 `reason` 时旧客户端仍给出可读文案。 + * `unknown` 是契约的前向兜底,后端将来新增 `reason` 时旧客户端仍给出可读文案;两个 helper + * 共用本表,避免各自维护一份 switch 后漂移。 */ +const REASON_COPY: Record< + ClientLlmModelUnavailableReason, + { label: string; fallback: string } +> = { + plan_required: { + label: '订阅计划不支持', + fallback: '所选模型需要更高订阅计划,已切换为默认模型', + }, + disabled: { + label: '该模型已下线', + fallback: '所选模型已下线,已切换为默认模型', + }, + unknown: { + label: '暂不可用', + fallback: '所选模型已不可用,已切回默认模型', + }, +}; + +/** 锁定项 hover / 键盘 focus 的原因文案。 */ export function unavailableModelReasonLabel( reason: ClientLlmModelUnavailableReason, ): string { - switch (reason) { - case 'plan_required': - return '订阅计划不支持'; - case 'disabled': - return '该模型已下线'; - default: - return '暂不可用'; - } + return (REASON_COPY[reason] ?? REASON_COPY.unknown).label; } /** @@ -27,12 +40,8 @@ export function unavailableModelReasonLabel( export function unavailableModelFallbackNotice( unavailableReason: ClientLlmModelUnavailableReason | undefined, ): string { - switch (unavailableReason) { - case 'plan_required': - return '所选模型需要更高订阅计划,已切换为默认模型'; - case 'disabled': - return '所选模型已下线,已切换为默认模型'; - default: - return '所选模型已不可用,已切回默认模型'; + if (!unavailableReason) { + return REASON_COPY.unknown.fallback; } + return (REASON_COPY[unavailableReason] ?? REASON_COPY.unknown).fallback; } From bf2addbae333b037500629c4bba3a6356eae8478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 13:53:29 +0800 Subject: [PATCH 197/232] =?UTF-8?q?=E9=87=8D=E6=9E=84(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E6=A1=A3=E4=BD=8D=E9=A1=B5):=20=E5=90=88=E5=B9=B6=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=9D=83=E9=99=90=E7=9A=84=E9=87=8D=E5=A4=8D=E6=96=87?= =?UTF-8?q?=E6=A1=88=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 表单选项与列表摘要原先各写一份档位文案,措辞还不一致 - 抽出 modelAccessLabels 单一表,表单选项由它派生,列表摘要直接查表 --- .../src/pages/AdminMembershipPlanPage.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx index 66885754d..f3692160b 100644 --- a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx +++ b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx @@ -35,13 +35,18 @@ interface AdminMembershipPlanPageProps { onResultChange: (result: ProfileMembershipPlanAdminResponse) => void; } +/** 模型权限的唯一文案表:表单选项与列表摘要共用,避免两处手工映射漂移。 */ +const modelAccessLabels: Record = { + basic: '仅基础模型', + full: '基础及高性能模型', +}; + const modelAccessOptions: Array<{ value: ProfileMembershipModelAccess; label: string; -}> = [ - { value: 'basic', label: '仅基础模型' }, - { value: 'full', label: '基础及高性能模型' }, -]; +}> = (Object.keys(modelAccessLabels) as ProfileMembershipModelAccess[]).map( + (value) => ({ value, label: modelAccessLabels[value] }), +); /** * 会员档位目录。 @@ -448,7 +453,7 @@ function sortPlanEntries(entries: ProfileMembershipPlanAdminResponse[]) { } function formatPlanLimits(entry: ProfileMembershipPlanAdminResponse) { - const modelLabel = entry.modelAccess === 'full' ? '基础+高性能' : '仅基础'; + const modelLabel = modelAccessLabels[entry.modelAccess]; const concurrency = entry.unlimitedConcurrency ? '不设上限' : `${entry.concurrentJobLimit} 个`; From 25bca13c312f8d6761c3f385a9353fcd9fd1ddd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 13:59:14 +0800 Subject: [PATCH 198/232] =?UTF-8?q?=E7=B1=BB=E5=9E=8B(=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E4=BC=9A=E5=91=98):=20=E4=BC=9A=E5=91=98=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=96=87=E6=A1=88=E8=A1=A8=E6=94=B9=E7=94=A8=E5=91=BD=E5=90=8D?= =?UTF-8?q?=E8=81=94=E5=90=88=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - membershipStatusLabels 原为 Record,新增/改名状态时编译期无法发现漏配 - 改用 @genarrative/shared 的 ProfileMembershipStatus(normal/active),并把 AdminProfileMembershipPayload.status 的内联字面量一并替换 --- apps/admin-web/src/api/adminApiTypes.ts | 4 +++- apps/admin-web/src/config/membershipDisplay.ts | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index dacf69cf6..f888d5913 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -2,6 +2,7 @@ import type { ProfileMembershipCycleKind, ProfileMembershipModelAccess, ProfileMembershipPlan, + ProfileMembershipStatus, } from '@genarrative/shared'; export interface ApiMeta { @@ -392,6 +393,7 @@ export type { ProfileMembershipCycleKind, ProfileMembershipModelAccess, ProfileMembershipPlan, + ProfileMembershipStatus, }; export interface AdminTrackingEventListQuery { eventKey?: string; @@ -1057,7 +1059,7 @@ export interface AdminUserDetailResponse { export interface AdminProfileMembershipPayload { plan: ProfileMembershipPlan; /** 与后端 `RuntimeProfileMembershipStatus` 对齐:`normal` / `active`。 */ - status: 'normal' | 'active'; + status: ProfileMembershipStatus; cycleKind: ProfileMembershipCycleKind; cycleIndex: number; cycleCount: number; diff --git a/apps/admin-web/src/config/membershipDisplay.ts b/apps/admin-web/src/config/membershipDisplay.ts index 411f76dca..3a903c5fb 100644 --- a/apps/admin-web/src/config/membershipDisplay.ts +++ b/apps/admin-web/src/config/membershipDisplay.ts @@ -1,6 +1,7 @@ import type { ProfileMembershipCycleKind, ProfileMembershipPlan, + ProfileMembershipStatus, } from '../api/adminApiTypes'; /** 会员档位展示名;`normal` 即非会员。 */ @@ -22,7 +23,7 @@ export const membershipCycleKindLabels: Record< }; /** 会员状态展示名;与后端 `RuntimeProfileMembershipStatus` 的 `normal` / `active` 对齐。 */ -export const membershipStatusLabels: Record = { +export const membershipStatusLabels: Record = { normal: '非会员', active: '生效中', }; @@ -39,5 +40,5 @@ export function formatMembershipCycleKind(kind: string): string { /** 把后端的会员状态 code 映射成展示名,未知值原样返回。 */ export function formatMembershipStatus(status: string): string { - return membershipStatusLabels[status] ?? status; + return membershipStatusLabels[status as ProfileMembershipStatus] ?? status; } From 3a948c5f58acf99a9fc84804469f18631d9dff29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 14:03:48 +0800 Subject: [PATCH 199/232] =?UTF-8?q?=E5=8A=9F=E8=83=BD(=E5=85=85=E5=80=BC?= =?UTF-8?q?=E5=BC=B9=E5=B1=82):=20=E4=BC=9A=E5=91=98=E5=91=A8=E6=9C=9F?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E6=94=AF=E6=8C=81=E9=94=AE=E7=9B=98=E6=96=B9?= =?UTF-8?q?=E5=90=91=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 月付/年付 radiogroup 原先无 roving tabindex,方向键无法移动选项 - 复用 resolveRovingIndex 与 ref,方向键同步移动选中与焦点;锁定时不响应方向键 - 补一条周期切换键盘导航测试 --- .../index.test.tsx | 46 +++++++++++++++++++ .../PlatformProfileRechargeModal/index.tsx | 28 +++++++++++ 2 files changed, 74 insertions(+) diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx index 16ee9f5d6..0de7d8fd2 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx @@ -197,6 +197,52 @@ describe('PlatformProfileRechargeModal', () => { ); }); + test('周期切换按钮支持方向键与 roving tabindex', async () => { + const user = userEvent.setup(); + + render( + , + ); + + const group = screen.getByRole('radiogroup', { name: '付费周期' }); + const monthly = within(group).getByRole('radio', { name: '月付' }); + const yearly = within(group).getByRole('radio', { name: '年付' }); + expect(monthly.getAttribute('tabindex')).toBe('0'); + expect(yearly.getAttribute('tabindex')).toBe('-1'); + + monthly.focus(); + await user.keyboard('{ArrowRight}'); + + expect(yearly.getAttribute('aria-checked')).toBe('true'); + expect(monthly.getAttribute('aria-checked')).toBe('false'); + expect(yearly.getAttribute('tabindex')).toBe('0'); + expect(monthly.getAttribute('tabindex')).toBe('-1'); + expect(document.activeElement).toBe(yearly); + }); + test('requests the server quote and shows the upgrade amount', async () => { const user = userEvent.setup(); const onRequestMembershipQuote = vi.fn(); diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index 62a948a47..10a92df03 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -641,6 +641,8 @@ function PlatformMembershipCycleSwitch({ { id: 'monthly', label: '月付' }, { id: 'yearly', label: '年付' }, ]; + // 与泥点档位一致:roving tabindex + 方向键移动焦点,保证键盘可切换周期。 + const optionRefs = useRef>({}); return (
@@ -648,15 +650,41 @@ function PlatformMembershipCycleSwitch({ role="radiogroup" aria-label="付费周期" className="platform-subpanel flex gap-1 rounded-full p-1" + onKeyDown={(event) => { + if (locked) { + return; + } + const currentIndex = options.findIndex( + (option) => option.id === cycleKind, + ); + const nextIndex = resolveRovingIndex( + event.key, + currentIndex, + options.length, + ); + if (nextIndex === null) { + return; + } + event.preventDefault(); + const nextOption = options[nextIndex]; + if (nextOption) { + onChange(nextOption.id); + optionRefs.current[nextOption.id]?.focus(); + } + }} > {options.map((option) => { const selected = option.id === cycleKind; return (
- 可同周期补差升档 -
- 周期切换需等到期后 + {hasUpgradePath ? ( + <> + 可同周期补差升档 +
+ 周期切换需等到期后 + + ) : ( + '周期切换需等到期后' + )}
); @@ -1362,6 +1371,13 @@ export function PlatformProfileRechargeModal({ ) ?? null, [center?.membershipPlans, membership?.plan], ); + // 已是最高的 max 档时没有可升级目标,状态卡不应再提示「可同周期补差升档」。 + const hasUpgradePath = useMemo( + () => + currentPlanRecord !== null && + membershipPlans.some((plan) => plan.rank > currentPlanRecord.rank), + [currentPlanRecord, membershipPlans], + ); const [activeTab, setActiveTab] = useState('subscription'); const [cycleKind, setCycleKind] = @@ -1488,6 +1504,7 @@ export function PlatformProfileRechargeModal({ ) : null} Date: Mon, 5 Oct 2026 14:10:42 +0800 Subject: [PATCH 202/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E5=85=85=E5=80=BC?= =?UTF-8?q?=E5=BC=B9=E5=B1=82):=20=E4=B8=8B=E5=8D=95=E8=BF=9B=E8=A1=8C?= =?UTF-8?q?=E4=B8=AD=E7=A6=81=E7=94=A8=E6=B3=A5=E7=82=B9=E7=BB=93=E7=AE=97?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 会员订单提交时泥点结算 CTA 仍可点,点击后会被宿主 buyRechargeProduct 守卫静默丢弃 - 泥点结算弹层传 ctaDisabled,底部「充值」CTA 也在任意提交进行中禁用 - 保留上一轮「会员提交不波及泥点档位可选性」的既有行为,仅收口下单动作;补回归测试 --- .../index.test.tsx | 50 +++++++++++++++++++ .../PlatformProfileRechargeModal/index.tsx | 5 +- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx index c1c5d6b41..0c15d0211 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx @@ -480,6 +480,56 @@ describe('PlatformProfileRechargeModal', () => { }); }); + test('会员订单提交中时泥点档位仍可选,但下单入口禁用', async () => { + const user = userEvent.setup(); + const renderModal = (submittingProductId: string | null) => ( + + ); + + const { rerender } = render(renderModal(null)); + await user.click(screen.getByRole('tab', { name: '泥点充值' })); + const product = within( + screen.getByRole('radiogroup', { name: '泥点充值档位' }), + ).getByRole('radio', { name: '60泥点 ¥6' }); + // 既有行为:会员订单提交不波及泥点档位的可选状态。 + expect(product).not.toHaveProperty('disabled', true); + + await user.click(product); + await user.click(screen.getByRole('button', { name: '充值 ¥6' })); + + // 会员订单提交中:泥点结算 CTA 禁用,避免被宿主守卫静默丢弃。 + rerender(renderModal('membership-pro-monthly')); + expect( + within(screen.getByRole('dialog', { name: '确认订单' })).getByRole( + 'button', + { name: '充值 ¥6' }, + ), + ).toHaveProperty('disabled', true); + }); + test('shows the empty state when the recharge tab has no point product', async () => { const user = userEvent.setup(); diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index 9d2451282..390df4a4d 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -1019,6 +1019,7 @@ function PlatformRechargeCheckoutDialog({ submitting={submitting} amountCents={product.priceCents} ctaLabel={`充值 ${formatRechargePrice(product.priceCents)}`} + ctaDisabled={Boolean(submittingProductId)} onClose={onClose} onSubmit={() => { onBuy(product); @@ -1575,7 +1576,9 @@ export function PlatformProfileRechargeModal({ /> { if (selectedPointProduct) { setCheckout({ From c3866b4267b140c668f3424a25c07e4a7f1038de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 14:22:39 +0800 Subject: [PATCH 203/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E9=82=80=E8=AF=B7=E7=A0=81):=20=E6=8B=92=E7=BB=9D=E8=A1=A5?= =?UTF-8?q?=E6=9C=BA=E5=99=A8=E5=8F=AF=E8=AF=BB=E9=94=99=E8=AF=AF=E7=A0=81?= =?UTF-8?q?=E8=B5=B0=20400?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module-runtime 新增 RuntimeProfileInviteCodeAdminErrorCode(invalid_field / in_use),两个后台邀请码 procedure 结果末尾追加 error_code - module 侧用 ProfileInviteCodeAdminProcedureError 在拒绝点赋码,字段校验统一 invalid_field、被他人占用归 in_use - spacetime-client mapper 有码走 ProcedureRejected,并把 api-server 映射注释收窄为「带 typed code 的四条链路」 - 重新生成 SpacetimeDB 绑定;补 client 与 api-server 单测;同步设计文档 §5.8 与决策日志 --- .../shared-memory/decision-log.md | 8 ++ ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 17 ++-- .../crates/api-server/src/runtime_profile.rs | 49 ++++++++-- server-rs/crates/module-runtime/src/domain.rs | 39 +++++++- .../crates/spacetime-client/src/active.rs | 3 +- .../src/active/mapper/runtime_profile.rs | 68 ++++++++++++-- .../spacetime-client/src/module_bindings.rs | 2 + ...ofile_invite_code_admin_error_code_type.rs | 18 ++++ ...e_code_admin_list_procedure_result_type.rs | 2 + ...invite_code_admin_procedure_result_type.rs | 2 + .../src/runtime/active/profile.rs | 92 +++++++++++++++---- 11 files changed, 259 insertions(+), 41 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_error_code_type.rs diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 65fabd8ff..f390729dc 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9728,3 +9728,11 @@ 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,module_bindings,module_bindings.rs}`、`server-rs/crates/api-server/src/runtime_profile.rs`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §5.8、本文件。 - 验证:`npm run spacetime:generate`;`cargo check`(module-runtime / spacetime-module / spacetime-client / api-server);`cargo test -p spacetime-client` 30 passed;`cargo test -p api-server runtime_profile::tests` 42 passed;`cargo test -p module-runtime --lib` 113 passed;`npm run check:spacetime-schema`、`npm run check:encoding`、`git diff --check` 通过。 + +## 2026-10-03 后台邀请码管理补机器可读错误码 + +- 背景:后台邀请码的 `admin_upsert_profile_invite_code` / `admin_list_profile_invite_codes` 与上一轮的后台兑换码同病——`map_runtime_profile_invite_code_admin_procedure_result` 把 `ok = false` 一律拍成 `Procedure(String)`,`邀请码已被其他用户占用` 这类明确业务拒绝在 `api-server` 落 502,且 `map_runtime_profile_client_error` 的注释夸大成 «profile procedure 的业务拒绝都已 typed»。 +- 决策:`module-runtime` 新增 `RuntimeProfileInviteCodeAdminErrorCode`(`InvalidField` / `InUse`);两个后台邀请码 procedure 结果末尾追加 `error_code: Option<...>`;`spacetime-module` 用 `ProfileInviteCodeAdminProcedureError::{Classified, Plain}` 赋码,字段校验归 `invalid_field`、被他人占用归 `in_use`;`RuntimeProfileProcedureRejectionCode` 增加 `InviteCodeAdmin` 聚合变体,`spacetime-client` mapper 有码走 `ProcedureRejected`、缺失时保持 `Procedure`;`api-server` 复用既有映射,并把注释收窄为 «带 typed error_code 的四条链路»。 +- 行为变化:后台邀请码的入参校验失败与 `邀请码已被其他用户占用` 由 502 变为 400 且带机器码;procedure result 新增字段仍要求 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,module_bindings,module_bindings.rs}`、`server-rs/crates/api-server/src/runtime_profile.rs`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §5.8、本文件。 +- 验证:`npm run spacetime:generate`;`cargo check`(module-runtime / spacetime-module / spacetime-client / api-server);`cargo test -p spacetime-client` 32 passed;`cargo test -p api-server runtime_profile::tests` 43 passed;`cargo test -p module-runtime --lib` 114 passed;`cargo test -p spacetime-module` 271 passed。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index e310e96e0..5f300ab28 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -460,15 +460,20 @@ flowchart TD `RuntimeProfileRedeemCodeAdminProcedureResult` / `RuntimeProfileRedeemCodeAdminListProcedureResult` 末尾追加 `error_code`;字段校验错误统一归 `invalid_field`,私有码缺可兑换用户归 `allowed_users_required`,停用不存在的码归 `not_found`。 +- 后台邀请码管理链路:`module-runtime` 的 `RuntimeProfileInviteCodeAdminErrorCode`(`InvalidField` / + `InUse`)在拒绝点显式产出,`RuntimeProfileInviteCodeAdminProcedureResult` / + `RuntimeProfileInviteCodeAdminListProcedureResult` 末尾追加 `error_code`;字段校验错误统一归 + `invalid_field`,邀请码被他人占用归 `in_use`。 - `spacetime-client` 的 `SpacetimeClientError::ProcedureRejected { code, message }` 用 - `RuntimeProfileProcedureRejectionCode` 聚合三条链路的 typed code;`api-server` 的 `runtime_profile` + `RuntimeProfileProcedureRejectionCode` 聚合四条链路的 typed code;`api-server` 的 `runtime_profile` 映射直接读变体字段返回 400(`provider = runtime-profile`),`AppError.code` 取 - `code.as_str()`,基础设施错误保持 502。会员 / 核销链路里未列出的字段校验类错误(如 user_id - 缺失)不产出 `error_code`,仍按字符串错误落到 502。 + `code.as_str()`,基础设施错误保持 502。尚未接 typed code 的 procedure 字段校验类错误(如 + user_id 缺失)不产出 `error_code`,仍按字符串错误落到 502。 - 行为变化:`会员目录缺少档位 ...`、「已迁移的会员商品」、兑换码核销的 `兑换码不存在` / - `已停用` / `未生效` / `已过期` / `次数已用完` / `不适用于当前账号` / `奖励无效`,以及后台兑换码 - 管理的 `兑换码不存在` / 入参校验失败 / 私有码缺可兑换用户原先落到 502,现在有明确错误码,统一 - 400。`api-server` 已删除 `is_runtime_profile_redeem_code_domain_error` 文案匹配函数。 + `已停用` / `未生效` / `已过期` / `次数已用完` / `不适用于当前账号` / `奖励无效`,后台兑换码 + 管理的 `兑换码不存在` / 入参校验失败 / 私有码缺可兑换用户,以及后台邀请码管理的入参校验失败 / + `邀请码已被其他用户占用` 原先落到 502,现在有明确错误码,统一 400。`api-server` 已删除 + `is_runtime_profile_redeem_code_domain_error` 文案匹配函数。 - 兼容边界:procedure result 是 wire 结构,新增字段要求 module 与 api-server 同版本部署; 旧 module 不返回 `error_code` 时,新 client 绑定反序列化会失败。 diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 981272697..18ee6dd6c 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1462,8 +1462,9 @@ pub async fn get_profile_play_stats( fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError { match &error { - // profile procedure 的业务拒绝:机器码与人读文案都取自 typed 变体, - // HTTP 状态、错误码和 message 全部结构化透传,不做任何字符串匹配或解析。 + // 带 typed error_code 的 profile procedure(会员、兑换码核销、后台兑换码管理、后台邀请码 + // 管理)业务拒绝:机器码与人读文案都取自 typed 变体,HTTP 状态、错误码和 message 全部 + // 结构化透传,不做任何字符串匹配或解析;其余 procedure 仍按裸字符串落下面的 502。 SpacetimeClientError::ProcedureRejected { code, message } => { AppError::from_status(StatusCode::BAD_REQUEST) .with_code(code.as_str()) @@ -2484,14 +2485,15 @@ mod tests { use module_auth::{ResolveWechatLoginInput, WechatIdentityProfile}; use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL, - RuntimeProfileDailyFreePointsRecord, RuntimeProfileMembershipCycleKind, - RuntimeProfileMembershipErrorCode, RuntimeProfileMembershipModelAccess, - RuntimeProfileMembershipPlan, RuntimeProfileMembershipPlanRecord, - RuntimeProfileMembershipRecord, RuntimeProfileMembershipStatus, - RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, - RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductKind, - RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeAdminErrorCode, - RuntimeProfileRewardCodeRedeemErrorCode, RuntimeProfileWalletLedgerSourceType, + RuntimeProfileDailyFreePointsRecord, RuntimeProfileInviteCodeAdminErrorCode, + RuntimeProfileMembershipCycleKind, RuntimeProfileMembershipErrorCode, + RuntimeProfileMembershipModelAccess, RuntimeProfileMembershipPlan, + RuntimeProfileMembershipPlanRecord, RuntimeProfileMembershipRecord, + RuntimeProfileMembershipStatus, RuntimeProfileRechargeCenterRecord, + RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, + RuntimeProfileRechargeProductKind, RuntimeProfileRechargeProductRecord, + RuntimeProfileRedeemCodeAdminErrorCode, RuntimeProfileRewardCodeRedeemErrorCode, + RuntimeProfileWalletLedgerSourceType, }; use super::{ @@ -2606,6 +2608,33 @@ mod tests { } } + #[test] + fn invite_code_admin_rejections_map_to_bad_request() { + // 后台邀请码管理与后台兑换码管理同构:「邀请码已被占用」/入参非法必须落 400 并带机器码, + // 不能因为缺 error_code 被当成 SpacetimeDB 基础设施故障落 502。 + for (code, expected_code, message) in [ + ( + RuntimeProfileInviteCodeAdminErrorCode::InvalidField, + "invalid_field", + "邀请码不能为空", + ), + ( + RuntimeProfileInviteCodeAdminErrorCode::InUse, + "in_use", + "邀请码已被其他用户占用", + ), + ] { + let error = map_runtime_profile_client_error(SpacetimeClientError::ProcedureRejected { + code: code.into(), + message: message.to_string(), + }); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST, "{message}"); + assert_eq!(error.code(), expected_code, "{message}"); + assert_eq!(error.message(), message); + } + } + #[test] fn membership_business_rejections_map_to_bad_request() { // 中文注释:会员业务拒绝由 procedure 透传机器可读 error_code,统一按 400 返回; diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index e09213f5b..2196436ce 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1593,8 +1593,31 @@ impl RuntimeProfileRedeemCodeAdminErrorCode { } } -/// profile procedure 业务拒绝的统一错误码:把会员、兑换码核销与后台兑换码管理的 typed code -/// 收在一处。 +/// 后台邀请码管理的机器可读拒绝码。 +/// +/// 与后台兑换码管理同一约定:邀请码增改的拒绝点显式产出本枚举并随 procedure 结果透传, +/// `api-server` 据此返回 400 而不是把业务拒绝当成 502。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeProfileInviteCodeAdminErrorCode { + /// 邀请码管理入参不合法(缺码、元数据不是对象、有效期倒挂等)。 + InvalidField, + /// 邀请码已被其他用户占用,不能改绑给当前用户。 + InUse, +} + +impl RuntimeProfileInviteCodeAdminErrorCode { + pub fn as_str(&self) -> &'static str { + match self { + Self::InvalidField => "invalid_field", + Self::InUse => "in_use", + } + } +} + +/// profile procedure 业务拒绝的统一错误码:把会员、兑换码核销、后台兑换码管理与后台邀请码管理 +/// 的 typed code 收在一处。 /// /// 只在 `spacetime-client` 进程内传递,不落表、不过 wire,因此不派生 `SpacetimeType`。 #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1602,6 +1625,7 @@ pub enum RuntimeProfileProcedureRejectionCode { Membership(RuntimeProfileMembershipErrorCode), RewardCodeRedeem(RuntimeProfileRewardCodeRedeemErrorCode), RedeemCodeAdmin(RuntimeProfileRedeemCodeAdminErrorCode), + InviteCodeAdmin(RuntimeProfileInviteCodeAdminErrorCode), } impl RuntimeProfileProcedureRejectionCode { @@ -1610,6 +1634,7 @@ impl RuntimeProfileProcedureRejectionCode { Self::Membership(code) => code.as_str(), Self::RewardCodeRedeem(code) => code.as_str(), Self::RedeemCodeAdmin(code) => code.as_str(), + Self::InviteCodeAdmin(code) => code.as_str(), } } } @@ -1632,6 +1657,12 @@ impl From for RuntimeProfileProcedureRej } } +impl From for RuntimeProfileProcedureRejectionCode { + fn from(code: RuntimeProfileInviteCodeAdminErrorCode) -> Self { + Self::InviteCodeAdmin(code) + } +} + /// 会员领域错误:机器可读 `code` + 人读 `message`。 /// /// 只在模块内部与 procedure 结果装载之间传递,不落 SpacetimeDB 表,因此不派生 `SpacetimeType`。 @@ -2793,6 +2824,8 @@ pub struct RuntimeProfileInviteCodeAdminProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + /// 业务拒绝的机器可读码;入参 / 系统错误为 `None`,由 `api-server` 落 502。 + pub error_code: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -2802,6 +2835,8 @@ pub struct RuntimeProfileInviteCodeAdminListProcedureResult { pub entries: Vec, pub operations: Vec, pub error_message: Option, + /// 业务拒绝的机器可读码;入参 / 系统错误为 `None`,由 `api-server` 落 502。 + pub error_code: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index ad74ec262..96dd91f75 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -235,7 +235,8 @@ pub enum SpacetimeClientError { Build(String), ConnectDropped, Procedure(String), - /// profile procedure 返回了带机器可读错误码的业务拒绝(会员 / 兑换码核销 / 后台兑换码管理)。 + /// profile procedure 返回了带机器可读错误码的业务拒绝(会员 / 兑换码核销 / 后台兑换码管理 / + /// 后台邀请码管理)。 ProcedureRejected { code: module_runtime::RuntimeProfileProcedureRejectionCode, message: String, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs index 58e7fe632..52720ef66 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs @@ -1325,11 +1325,13 @@ pub(crate) fn map_runtime_profile_invite_code_admin_procedure_result( result: RuntimeProfileInviteCodeAdminProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); + return Err(match result.error_code { + Some(code) => SpacetimeClientError::procedure_rejected( + map_runtime_profile_invite_code_admin_error_code_back(code), + result.error_message, + ), + None => SpacetimeClientError::procedure_failed(result.error_message), + }); } let snapshot = result.record.ok_or_else(|| { @@ -1345,7 +1347,13 @@ pub(crate) fn map_runtime_profile_invite_code_admin_list_procedure_result( result: RuntimeProfileInviteCodeAdminListProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); + return Err(match result.error_code { + Some(code) => SpacetimeClientError::procedure_rejected( + map_runtime_profile_invite_code_admin_error_code_back(code), + result.error_message, + ), + None => SpacetimeClientError::procedure_failed(result.error_message), + }); } Ok(RuntimeProfileInviteCodeAdminListRecord { @@ -2169,6 +2177,19 @@ pub(crate) fn map_runtime_profile_redeem_code_admin_error_code_back( } } +pub(crate) fn map_runtime_profile_invite_code_admin_error_code_back( + value: crate::module_bindings::RuntimeProfileInviteCodeAdminErrorCode, +) -> module_runtime::RuntimeProfileInviteCodeAdminErrorCode { + match value { + crate::module_bindings::RuntimeProfileInviteCodeAdminErrorCode::InvalidField => { + module_runtime::RuntimeProfileInviteCodeAdminErrorCode::InvalidField + } + crate::module_bindings::RuntimeProfileInviteCodeAdminErrorCode::InUse => { + module_runtime::RuntimeProfileInviteCodeAdminErrorCode::InUse + } + } +} + pub(crate) fn map_runtime_profile_membership_model_access( value: module_runtime::RuntimeProfileMembershipModelAccess, ) -> crate::module_bindings::RuntimeProfileMembershipModelAccess { @@ -2553,4 +2574,39 @@ mod tests { other => panic!("expected Procedure, got {other:?}"), } } + + #[test] + fn admin_invite_code_rejection_uses_typed_code() { + let result = RuntimeProfileInviteCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some("邀请码已被其他用户占用".to_string()), + error_code: Some(crate::module_bindings::RuntimeProfileInviteCodeAdminErrorCode::InUse), + }; + + match map_runtime_profile_invite_code_admin_procedure_result(result) { + Err(SpacetimeClientError::ProcedureRejected { code, message }) => { + assert_eq!(code.as_str(), "in_use"); + assert_eq!(message, "邀请码已被其他用户占用"); + } + other => panic!("expected ProcedureRejected, got {other:?}"), + } + } + + #[test] + fn admin_invite_code_without_error_code_stays_plain_procedure_error() { + let result = RuntimeProfileInviteCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some("SpacetimeDB 事务失败".to_string()), + error_code: None, + }; + + match map_runtime_profile_invite_code_admin_procedure_result(result) { + Err(SpacetimeClientError::Procedure(message)) => { + assert_eq!(message, "SpacetimeDB 事务失败"); + } + other => panic!("expected Procedure, got {other:?}"), + } + } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index e7d05b5ab..ce355243b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -707,6 +707,7 @@ pub mod runtime_profile_feedback_status_type; pub mod runtime_profile_feedback_submission_input_type; pub mod runtime_profile_feedback_submission_procedure_result_type; pub mod runtime_profile_feedback_submission_snapshot_type; +pub mod runtime_profile_invite_code_admin_error_code_type; pub mod runtime_profile_invite_code_admin_list_input_type; pub mod runtime_profile_invite_code_admin_list_procedure_result_type; pub mod runtime_profile_invite_code_admin_procedure_result_type; @@ -1594,6 +1595,7 @@ pub use runtime_profile_feedback_status_type::RuntimeProfileFeedbackStatus; pub use runtime_profile_feedback_submission_input_type::RuntimeProfileFeedbackSubmissionInput; pub use runtime_profile_feedback_submission_procedure_result_type::RuntimeProfileFeedbackSubmissionProcedureResult; pub use runtime_profile_feedback_submission_snapshot_type::RuntimeProfileFeedbackSubmissionSnapshot; +pub use runtime_profile_invite_code_admin_error_code_type::RuntimeProfileInviteCodeAdminErrorCode; pub use runtime_profile_invite_code_admin_list_input_type::RuntimeProfileInviteCodeAdminListInput; pub use runtime_profile_invite_code_admin_list_procedure_result_type::RuntimeProfileInviteCodeAdminListProcedureResult; pub use runtime_profile_invite_code_admin_procedure_result_type::RuntimeProfileInviteCodeAdminProcedureResult; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_error_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_error_code_type.rs new file mode 100644 index 000000000..cd112299d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_error_code_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileInviteCodeAdminErrorCode { + InvalidField, + + InUse, +} + +impl __sdk::InModule for RuntimeProfileInviteCodeAdminErrorCode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_list_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_list_procedure_result_type.rs index db56e3c0f..359435447 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_list_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_list_procedure_result_type.rs @@ -5,6 +5,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; use super::runtime_profile_code_operation_snapshot_type::RuntimeProfileCodeOperationSnapshot; +use super::runtime_profile_invite_code_admin_error_code_type::RuntimeProfileInviteCodeAdminErrorCode; use super::runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -14,6 +15,7 @@ pub struct RuntimeProfileInviteCodeAdminListProcedureResult { pub entries: Vec, pub operations: Vec, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileInviteCodeAdminListProcedureResult { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs index 70f543006..4acbdce5a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs @@ -4,6 +4,7 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_invite_code_admin_error_code_type::RuntimeProfileInviteCodeAdminErrorCode; use super::runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -12,6 +13,7 @@ pub struct RuntimeProfileInviteCodeAdminProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileInviteCodeAdminProcedureResult { 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 c240523c1..c9df58b41 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -173,6 +173,53 @@ fn redeem_code_admin_error_from_field_error( ) } +/// 后台邀请码管理 procedure 的错误载体。 +/// +/// 与 [`ProfileRedeemCodeAdminProcedureError`] 同一约定:业务拒绝带机器可读 +/// [`RuntimeProfileInviteCodeAdminErrorCode`],其它错误保持普通字符串。 +enum ProfileInviteCodeAdminProcedureError { + Classified(RuntimeProfileInviteCodeAdminErrorCode, String), + Plain(String), +} + +impl From for ProfileInviteCodeAdminProcedureError { + fn from(message: String) -> Self { + Self::Plain(message) + } +} + +impl From<&str> for ProfileInviteCodeAdminProcedureError { + fn from(message: &str) -> Self { + Self::Plain(message.to_string()) + } +} + +impl ProfileInviteCodeAdminProcedureError { + fn classified( + code: RuntimeProfileInviteCodeAdminErrorCode, + message: impl Into, + ) -> Self { + Self::Classified(code, message.into()) + } + + fn into_parts(self) -> (String, Option) { + match self { + Self::Classified(code, message) => (message, Some(code)), + Self::Plain(message) => (message, None), + } + } +} + +/// 后台邀请码的字段校验错误统一归入 `invalid_field`,保证入参拒绝也落 400 而不是 502。 +fn invite_code_admin_error_from_field_error( + error: RuntimeProfileFieldError, +) -> ProfileInviteCodeAdminProcedureError { + ProfileInviteCodeAdminProcedureError::classified( + RuntimeProfileInviteCodeAdminErrorCode::InvalidField, + error.to_string(), + ) +} + #[spacetimedb::table(accessor = profile_dashboard_state)] pub struct ProfileDashboardState { #[primary_key] @@ -2818,12 +2865,17 @@ pub fn admin_upsert_profile_invite_code( ok: true, record: Some(record), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileInviteCodeAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileInviteCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + error_code, + } + } } } @@ -2838,13 +2890,18 @@ pub fn admin_list_profile_invite_codes( entries, operations, error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileInviteCodeAdminListProcedureResult { - ok: false, - entries: Vec::new(), - operations: Vec::new(), - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileInviteCodeAdminListProcedureResult { + ok: false, + entries: Vec::new(), + operations: Vec::new(), + error_message: Some(message), + error_code, + } + } } } @@ -7919,7 +7976,7 @@ fn admin_disable_profile_redeem_code_record( fn admin_upsert_profile_invite_code_record( ctx: &ReducerContext, input: RuntimeProfileInviteCodeAdminUpsertInput, -) -> Result { +) -> Result { let validated_input = build_runtime_profile_invite_code_admin_upsert_input( input.admin_user_id, input.invite_code, @@ -7928,7 +7985,7 @@ fn admin_upsert_profile_invite_code_record( input.expires_at_micros, input.updated_at_micros, ) - .map_err(|error| error.to_string())?; + .map_err(invite_code_admin_error_from_field_error)?; let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); let user_id = build_admin_profile_invite_code_user_id( &validated_input.admin_user_id, @@ -7944,7 +8001,10 @@ fn admin_upsert_profile_invite_code_record( .find(&validated_input.invite_code) { if existing.user_id != user_id { - return Err("邀请码已被其他用户占用".to_string()); + return Err(ProfileInviteCodeAdminProcedureError::classified( + RuntimeProfileInviteCodeAdminErrorCode::InUse, + "邀请码已被其他用户占用", + )); } ctx.db .profile_invite_code() @@ -8545,10 +8605,10 @@ fn admin_list_profile_invite_code_records( Vec, Vec, ), - String, + ProfileInviteCodeAdminProcedureError, > { let _validated_input = build_runtime_profile_invite_code_admin_list_input(input.admin_user_id) - .map_err(|error| error.to_string())?; + .map_err(invite_code_admin_error_from_field_error)?; let mut entries = ctx .db From c85da3a9839f2ee4a191a606d48b55be6b832053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 15:11:06 +0800 Subject: [PATCH 204/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(AGC=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B):=20=E6=A1=A3=E4=BD=8D=E6=97=A0=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=94=B9=E7=94=A8=E7=8B=AC=E7=AB=8B=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module-runtime 新增 MODEL_UNAVAILABLE / MODEL_UNAVAILABLE_FOR_TIER 常量,NoModelForTier.code() 由共用 MODEL_UNAVAILABLE 改为 MODEL_UNAVAILABLE_FOR_TIER(仍 503),与单个模型不可用的 422 区分 - 更新 resolve_error_codes_are_stable 与 api-server llm 单测;收窄 llm/model_access 模块注释 - 同步设计文档 §11.4 与决策日志 --- .../shared-memory/decision-log.md | 7 ++++++ ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 4 ++-- server-rs/crates/api-server/src/llm/mod.rs | 10 ++++---- .../crates/api-server/src/llm/model_access.rs | 7 +++--- .../module-runtime/src/agc_model_access.rs | 24 ++++++++++++------- 5 files changed, 35 insertions(+), 17 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f390729dc..c81ab03c1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9736,3 +9736,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 行为变化:后台邀请码的入参校验失败与 `邀请码已被其他用户占用` 由 502 变为 400 且带机器码;procedure result 新增字段仍要求 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,module_bindings,module_bindings.rs}`、`server-rs/crates/api-server/src/runtime_profile.rs`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §5.8、本文件。 - 验证:`npm run spacetime:generate`;`cargo check`(module-runtime / spacetime-module / spacetime-client / api-server);`cargo test -p spacetime-client` 32 passed;`cargo test -p api-server runtime_profile::tests` 43 passed;`cargo test -p module-runtime --lib` 114 passed;`cargo test -p spacetime-module` 271 passed。 + +## 2026-10-03 档位无可用模型改用独立错误码 MODEL_UNAVAILABLE_FOR_TIER + +- 背景:`AgcModelResolveError::Unavailable`(422)与 `NoModelForTier`(503)此前共用 `MODEL_UNAVAILABLE`(见本文件 2026-10-03 的列表契约条目),只按 `error.code` 分派的客户端无法分辨「单个模型不可用」与「整个档位无可用模型(目录 / 档位配置问题)」,两者修法不同。 +- 决策:`module-runtime` 新增常量 `MODEL_UNAVAILABLE` 与 `MODEL_UNAVAILABLE_FOR_TIER`,`NoModelForTier.code()` 返回后者,`Unavailable` 仍返回 `MODEL_UNAVAILABLE`;HTTP 状态(422 / 503)与端点行为不变,仅把错误码拆细,`resolve_error_codes_are_stable` 同步断言三码两两不同。 +- 影响范围:`server-rs/crates/module-runtime/src/agc_model_access.rs`、`server-rs/crates/api-server/src/llm/{mod,model_access}.rs`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §11.4、本文件。 +- 验证:`cargo test -p module-runtime --lib agc_model_access`;`cargo test -p api-server llm::tests`。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index 5f300ab28..ae6b19de4 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -628,11 +628,11 @@ flowchart TD ### 11.4 实现落点(2026-10-03 编码约定) - **`module-runtime`**: - - 新增 `agc_model_access.rs`:权限档 `AgcModelAccess`(`basic` / `full`,`Default = Basic`)、越权错误码常量 `MODEL_NOT_AVAILABLE_FOR_PLAN`、解析错误 `AgcModelResolveError::{Unavailable, NoModelForTier, NotAvailableForPlan}`(`Unavailable` 与 `NoModelForTier` 复用 `MODEL_UNAVAILABLE` 码,分别映射 422 / 503),以及 `RuntimeProfileMembershipModelAccess → AgcModelAccess` 的映射。 + - 新增 `agc_model_access.rs`:权限档 `AgcModelAccess`(`basic` / `full`,`Default = Basic`)、错误码常量 `MODEL_NOT_AVAILABLE_FOR_PLAN` / `MODEL_UNAVAILABLE` / `MODEL_UNAVAILABLE_FOR_TIER`、解析错误 `AgcModelResolveError::{Unavailable, NoModelForTier, NotAvailableForPlan}`(分别对应 422 `MODEL_UNAVAILABLE`、503 `MODEL_UNAVAILABLE_FOR_TIER`、403 `MODEL_NOT_AVAILABLE_FOR_PLAN`),以及 `RuntimeProfileMembershipModelAccess → AgcModelAccess` 的映射。 - `AgcModel` 末位追加 `#[serde(default)] access: AgcModelAccess`(存量目录 JSON 缺字段即 `Basic`,失败开放);`AgcModelCatalog` 新增 `available_models_for(access)`、`default_model_id_for(access)`、`resolve_requested_for(requested, access)`。 - **默认模型语义**:优先取目录登记的 `default_model_id`;当它不在该档可用集合里时,回退到目录顺序里第一个可用项;该档一个可用模型都没有时返回 `Unavailable`。 - **`spacetime-module`**:`external_generation_job` 追加 btree 索引 `(owner_user_id, status)`(`by_external_generation_job_owner_status`);`claim_external_generation_jobs_tx` 按账号解析 `concurrent_job_limit` 并在认领时过滤;上限解析走 `profile_membership.plan` → `profile_membership_plan.concurrent_job_limit`,缺会员行 / 缺目录行失败关闭到 `Normal`(=1)。 -- **`api-server`**:新增 `llm/model_access.rs`,调专用只读 procedure `get_profile_agc_model_access_and_return`(只读会员账期投影 + 档位目录 `model_access`,不刷新、不写库)并映射到 `AgcModelAccess`;`GET /api/llm/models` 按档过滤,`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全部按档解析,越权返回 403 + 错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`,该档无可用模型 / 所选模型不可用分别返回 503 / 422 + `MODEL_UNAVAILABLE`。`resolve_llm_router_client` 返回 `Result<_, AppError>`,会员目录 `503 + MODEL_ACCESS_UNAVAILABLE` 与上述模型解析错误都通过 typed `AppError` 透传,不再拍平成中文。 +- **`api-server`**:新增 `llm/model_access.rs`,调专用只读 procedure `get_profile_agc_model_access_and_return`(只读会员账期投影 + 档位目录 `model_access`,不刷新、不写库)并映射到 `AgcModelAccess`;`GET /api/llm/models` 按档过滤,`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全部按档解析,越权返回 403 + 错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`,所选模型不可用返回 422 + `MODEL_UNAVAILABLE`,该档无可用模型返回 503 + `MODEL_UNAVAILABLE_FOR_TIER`(与单个模型不可用区分,客户端只按 code 也能分辨)。`resolve_llm_router_client` 返回 `Result<_, AppError>`,会员目录 `503 + MODEL_ACCESS_UNAVAILABLE` 与上述模型解析错误都通过 typed `AppError` 透传,不再拍平成中文。 - **后台**:`AdminAgcModel` 与后台「AGC 模型」页新增 `access` 字段(`basic` / `full`,缺省 `basic`),GET 回读、PUT 写入;`AdminAgcModel.access` 与 `AdminUpsertProfileMembershipPlanRequest.model_access` 一样用 `ProfileMembershipModelAccessToken`,未知 token 反序列化即拒;**不做**自动播种或代码内清单。 - **性能说明(2026-10-03 已落地)**:账号档位走专用只读 procedure `get_profile_agc_model_access_and_return`(`9e397fe9c`),不再触发 `get_profile_recharge_center` 的账期 / 免费点刷新;`api-server` 于 `b0dc94b46` 切到该读。认领按账号折叠并发查询见 `c3c3fc5a2`。缺会员行 / 已过期按 `Normal`,缺档位目录行失败关闭到 `Basic`。 - **列表契约(2026-10-03 修订)**:`shared-contracts` 新增 `llm_catalog.rs` 承载 `LlmModelsResponse` / `LlmModelSummary` / `LlmUnavailableModel` / `LlmModelUnavailableReason`(`plan_required` / `disabled` / `unknown`);`AgcAgentMode` / `AgcModelProtocol` 从 `module-runtime` 迁入 `shared-contracts` 并由 `module-runtime` re-export;整个目录 DTO 与枚举用 `#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]` 导出到 `packages/shared/src/contracts/generated/`,`revision` 标 `#[ts(as = "f64")]`,AGC 前端从 `@genarrative/shared` 根导入,删掉手写的 `ClientLlmModel`。 diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index ab4c6a8db..bbff617a8 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -128,11 +128,12 @@ mod model_catalog_tests { } #[test] - fn tier_without_model_maps_to_service_unavailable_with_model_unavailable_code() { - // 与 `/api/llm/models` 列表端点同一条件、同一状态码:代理路径不能再落到 422。 + fn tier_without_model_maps_to_service_unavailable_with_dedicated_code() { + // 与 `/api/llm/models` 列表端点同一条件、同一状态码:代理路径不能再落到 422; + // 码也不再与「单个模型不可用」共用 MODEL_UNAVAILABLE。 let error = agc_model_resolve_error(module_runtime::AgcModelResolveError::NoModelForTier); assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(error.code(), "MODEL_UNAVAILABLE"); + assert_eq!(error.code(), module_runtime::MODEL_UNAVAILABLE_FOR_TIER); } #[test] @@ -280,7 +281,8 @@ fn public_model_catalog( ) -> Result { let OwnerAgcModelCatalog { catalog, access } = owner_catalog; // 该档一个可用模型都没有属于后台目录配置问题:明确报错,而不是下发一个不可解析的默认项。 - // 与代理路径共用 `agc_model_resolve_error`,保证同一条件在两个端点都是 503 + MODEL_UNAVAILABLE。 + // 与代理路径共用 `agc_model_resolve_error`,保证同一条件在两个端点都是 + // 503 + MODEL_UNAVAILABLE_FOR_TIER(与单个模型不可用的 422 + MODEL_UNAVAILABLE 区分开)。 let default_model_id = catalog .default_model_id_for(access) .ok_or_else(|| agc_model_resolve_error(AgcModelResolveError::NoModelForTier))? diff --git a/server-rs/crates/api-server/src/llm/model_access.rs b/server-rs/crates/api-server/src/llm/model_access.rs index 3e538ee05..a1760483c 100644 --- a/server-rs/crates/api-server/src/llm/model_access.rs +++ b/server-rs/crates/api-server/src/llm/model_access.rs @@ -3,10 +3,11 @@ //! 模型目录本身是全局的,权限判定在 `module-runtime`;这里只负责把「当前账号」解析成 //! [`AgcModelAccess`],再把全局目录与该档位打包给路由层使用。 //! -//! LLM 路由对外错误码(三条互不重叠,客户端据此区分处理): +//! LLM 路由对外错误码(四条互不重叠,客户端据此区分处理): //! - `MODEL_NOT_AVAILABLE_FOR_PLAN`(403):模型存在且启用,但当前会员档位不够(`module-runtime` 解析)。 -//! - `MODEL_UNAVAILABLE`(422;该档在目录里没有任何可用模型时 503):模型不在目录或已停用; -//! 「整个档位没有可用模型」属于目录 / 档位配置问题,列表端点与代理端点统一按 503 上报。 +//! - `MODEL_UNAVAILABLE`(422):模型不在目录或已停用。 +//! - `MODEL_UNAVAILABLE_FOR_TIER`(503):该档在目录里没有任何可用模型,属目录 / 档位配置问题, +//! 列表端点与代理端点统一按 503 上报,与单个模型不可用(422)区分。 //! - `MODEL_ACCESS_UNAVAILABLE`(503):读取账号权限档本身失败(SpacetimeDB 传输 / procedure / 鉴权)。 //! 注意**缺会员行 / 缺档位目录行不算这条**:module procedure 已按 `Basic` 失败关闭并返回 `ok: true`。 //! 能走到这条说明读链路不可用,不能把付费账号静默降级成 `Basic`。 diff --git a/server-rs/crates/module-runtime/src/agc_model_access.rs b/server-rs/crates/module-runtime/src/agc_model_access.rs index 67c0109b7..5e81a77fb 100644 --- a/server-rs/crates/module-runtime/src/agc_model_access.rs +++ b/server-rs/crates/module-runtime/src/agc_model_access.rs @@ -20,6 +20,15 @@ use shared_contracts::llm_catalog::LlmModelUnavailableReason; /// 越权选择模型时对外返回的专用错误码(`api-server` 直接透传到 `AppError.code`)。 pub const MODEL_NOT_AVAILABLE_FOR_PLAN: &str = "MODEL_NOT_AVAILABLE_FOR_PLAN"; +/// 所选模型不在目录或已停用时对外返回的错误码。 +pub const MODEL_UNAVAILABLE: &str = "MODEL_UNAVAILABLE"; + +/// 当前会员档位在目录里没有任何可用模型时对外返回的错误码。 +/// +/// 与 [`MODEL_UNAVAILABLE`] 区分:后者是「单个模型的问题」(422),本码是「目录 / 档位配置 +/// 问题」(503),修法不同,客户端只按 `error.code` 也能分辨。 +pub const MODEL_UNAVAILABLE_FOR_TIER: &str = "MODEL_UNAVAILABLE_FOR_TIER"; + /// 单个模型要求的权限档。 /// /// 缺省 `Basic`(失败开放):存量目录 JSON 缺该字段、后台未人工标注都按基础档处理, @@ -101,7 +110,8 @@ pub enum AgcModelResolveError { impl AgcModelResolveError { pub const fn code(self) -> &'static str { match self { - Self::Unavailable | Self::NoModelForTier => "MODEL_UNAVAILABLE", + Self::Unavailable => MODEL_UNAVAILABLE, + Self::NoModelForTier => MODEL_UNAVAILABLE_FOR_TIER, Self::NotAvailableForPlan => MODEL_NOT_AVAILABLE_FOR_PLAN, } } @@ -343,16 +353,14 @@ mod tests { AgcModelResolveError::NotAvailableForPlan.code(), MODEL_NOT_AVAILABLE_FOR_PLAN ); - assert_eq!( - AgcModelResolveError::Unavailable.code(), - "MODEL_UNAVAILABLE" - ); - // 「该档没有可用模型」沿用 MODEL_UNAVAILABLE 错误码,但调用方按变体把它映射到 503, - // 与列表端点的状态保持一致。 + assert_eq!(AgcModelResolveError::Unavailable.code(), MODEL_UNAVAILABLE); + // 「该档没有可用模型」是目录 / 档位配置问题(503),与单个模型不可用(422)用不同的码, + // 让只按 error.code 分派的客户端也能区分。 assert_eq!( AgcModelResolveError::NoModelForTier.code(), - "MODEL_UNAVAILABLE" + MODEL_UNAVAILABLE_FOR_TIER ); + assert_ne!(MODEL_UNAVAILABLE, MODEL_UNAVAILABLE_FOR_TIER); } #[test] From 42c4cacf723e68e496ff672637ae582d275fd4e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 15:11:47 +0800 Subject: [PATCH 205/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E5=A5=91=E7=BA=A6):=20=E5=93=8D=E5=BA=94=20token=20=E5=8F=8D?= =?UTF-8?q?=E5=BA=8F=E5=88=97=E5=8C=96=E5=89=8D=E5=90=91=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared-contracts 为会员响应 DTO 的 plan / status / cycle_kind / model_access 字段加 deserialize_with,未知取值 fail-open 兜底到已知最高档,避免 AGC Tauri shell 等旧 Rust 客户端因后端新增 token 整包解析失败 - 入参 DTO 保持严格拒绝,不改枚举自身 Deserialize,避免 Unknown 变体污染入参 - 补响应兼容与入参严格测试;同步设计文档 §2.5 与决策日志 --- .../shared-memory/decision-log.md | 7 + ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 8 +- .../crates/shared-contracts/src/runtime.rs | 153 +++++++++++++++++- 3 files changed, 160 insertions(+), 8 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c81ab03c1..9fe895771 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9743,3 +9743,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:`module-runtime` 新增常量 `MODEL_UNAVAILABLE` 与 `MODEL_UNAVAILABLE_FOR_TIER`,`NoModelForTier.code()` 返回后者,`Unavailable` 仍返回 `MODEL_UNAVAILABLE`;HTTP 状态(422 / 503)与端点行为不变,仅把错误码拆细,`resolve_error_codes_are_stable` 同步断言三码两两不同。 - 影响范围:`server-rs/crates/module-runtime/src/agc_model_access.rs`、`server-rs/crates/api-server/src/llm/{mod,model_access}.rs`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §11.4、本文件。 - 验证:`cargo test -p module-runtime --lib agc_model_access`;`cargo test -p api-server llm::tests`。 + +## 2026-10-03 会员响应 token 反序列化前向兼容 + +- 背景:`shared-contracts` 的 5 个会员 token 枚举同时被入参 / 响应 DTO 复用,此前响应侧也严格反序列化;后端一旦新增 plan / status / cycle 取值,AGC Tauri shell 的 `read_profile_recharge_center` / `read_profile_membership_upgrade_quote`(`account_api.rs` 用 `serde_json::from_value` 解析整份响应)就会得到 `响应格式无效`,整个钱包页不可用。 +- 决策:只给**响应** DTO 的 token 字段挂 `#[serde(deserialize_with = "...")]` 做前向兼容,未知取值 fail-open 兜底到已知最高档(plan→`max`、status→`active`、cycle→`yearly`、model_access→`full`;真实权益 / 下单金额以后端重算为准,避免把付费会员显示成非会员后引导重复购买)。**入参** DTO 不改,非法 token 仍在反序列化即拒;不改枚举本身的 `Deserialize`,避免 `Unknown` 变体污染入参与穷尽 match。 +- 影响范围:`server-rs/crates/shared-contracts/src/runtime.rs`(4 个 helper + `ProfileMembershipPlanResponse` / `ProfileMembershipResponse` / `ProfileMembershipUpgradeQuoteResponse` 的 8 个字段)、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §2.5。后台 DTO(`AdminProfileMembershipPayload` / `AdminMembershipOrderChangePayload`)只有 TS 消费方,不做 Rust 侧处理。 +- 验证:`cargo test -p shared-contracts --lib` 106 passed(含 `membership_response_tokens_tolerate_unknown_values_from_newer_backend`、`membership_request_tokens_stay_strict_for_unknown_values`)。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index ae6b19de4..49bb7b3b7 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -153,8 +153,12 @@ pub struct ProfileMembershipPlan { - `shared-contracts` 新增 wire 侧 `ProfileMembershipPlanToken` / `ProfileMembershipCycleKindToken` / `ProfileMembershipStatusToken` / `ProfileMembershipModelAccessToken` / `ProfileMembershipChangeKindToken`, 把 DTO 里原来的裸 `String`(后台 upsert 请求、后台会员详情、订单变更快照、会员 / 档位 / 报价响应) - 收口为 typed enum;与模块枚举通过 `From` 双向映射,JSON wire 值仍是小写 token,未知 token 在 - 反序列化阶段被拒。这 5 个 token 枚举统一 `#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]` + 收口为 typed enum;与模块枚举通过 `From` 双向映射,JSON wire 值仍是小写 token。**入参** DTO + (`ProfileMembershipUpgradeQuoteRequest`、`AdminUpsertProfileMembershipPlanRequest`)反序列化保持严格, + 未知 token 即拒;**响应** DTO 的 token 字段挂 `deserialize_with` 做前向兼容,未知取值按 fail-open + 兜底到已知最高档(plan→`max`、status→`active`、cycle→`yearly`、model_access→`full`), + 避免 AGC Tauri shell 等 Rust 客户端因后端新增一个 token 就整包解析失败(权益与下单金额仍以后端重算为准)。 + 这 5 个 token 枚举统一 `#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]` 导出到 `packages/shared/src/contracts/generated/`,前端只 re-export,不再手写字符串联合类型。 - **删除** `RuntimeProfileMembershipTier { Normal, Month, Season, Year, Starter, Basic, Pro, Ultimate }` 及其 `as_str()`(会员侧零存量,用户已授权破坏性变更)。 diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index be73898d7..927446144 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -395,6 +395,76 @@ impl ProfileMembershipChangeKindToken { } } +/// 响应侧前向兼容反序列化:后端新增 token 取值时,旧 Rust 客户端(AGC Tauri shell 的 +/// `account_api` 会把整份 `ProfileRechargeCenterResponse` / `...QuoteResponse` 反序列化) +/// 不应因为一个枚举取值而整包失败。 +/// +/// 只用于**响应** DTO 字段;入参 DTO(`ProfileMembershipUpgradeQuoteRequest`、 +/// `AdminUpsertProfileMembershipPlanRequest`)继续用严格 enum,非法 token 仍然拒绝。 +/// +/// 未知取值统一落到「已知的最高能力」档(plan→`max`、status→`active`、cycle→`yearly`、 +/// model_access→`full`):展示上按 fail-open 处理,真实权益与下单金额一律以后端重算为准, +/// 避免把付费会员误显示成非会员后引导重复购买。 +fn deserialize_forward_compat_membership_plan<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok( + match Option::::deserialize(deserializer)?.as_deref() { + Some("normal") => ProfileMembershipPlanToken::Normal, + Some("starter") => ProfileMembershipPlanToken::Starter, + Some("plus") => ProfileMembershipPlanToken::Plus, + Some("pro") => ProfileMembershipPlanToken::Pro, + Some("max") => ProfileMembershipPlanToken::Max, + _ => ProfileMembershipPlanToken::Max, + }, + ) +} + +fn deserialize_forward_compat_membership_status<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok( + match Option::::deserialize(deserializer)?.as_deref() { + Some("normal") => ProfileMembershipStatusToken::Normal, + _ => ProfileMembershipStatusToken::Active, + }, + ) +} + +fn deserialize_forward_compat_membership_cycle_kind<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok( + match Option::::deserialize(deserializer)?.as_deref() { + Some("monthly") => ProfileMembershipCycleKindToken::Monthly, + _ => ProfileMembershipCycleKindToken::Yearly, + }, + ) +} + +fn deserialize_forward_compat_model_access<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok( + match Option::::deserialize(deserializer)?.as_deref() { + Some("basic") => ProfileMembershipModelAccessToken::Basic, + _ => ProfileMembershipModelAccessToken::Full, + }, + ) +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileRechargeProductResponse { @@ -412,14 +482,16 @@ pub struct ProfileRechargeProductResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileMembershipPlanResponse { - /// 档位 token:`normal` / `starter` / `plus` / `pro` / `max`。 + /// 档位 token:`normal` / `starter` / `plus` / `pro` / `max`;未知取值按 `max` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_membership_plan")] pub plan: ProfileMembershipPlanToken, pub title: String, pub rank: u8, pub month_price_cents: u64, pub year_price_cents: u64, pub period_points: u64, - /// 模型权限 token:`basic` / `full`。 + /// 模型权限 token:`basic` / `full`;未知取值按 `full` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_model_access")] pub model_access: ProfileMembershipModelAccessToken, /// 并发上限;`128` 表示不设上限。 pub concurrent_job_limit: u32, @@ -433,9 +505,11 @@ pub struct ProfileMembershipPlanResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileMembershipResponse { - /// 会员状态 token:`normal` / `active`。 + /// 会员状态 token:`normal` / `active`;未知取值按 `active` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_membership_status")] pub status: ProfileMembershipStatusToken, - /// 当前档位 token;非会员与已到期都是 `normal`。 + /// 当前档位 token;非会员与已到期都是 `normal`;未知取值按 `max` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_membership_plan")] pub plan: ProfileMembershipPlanToken, pub started_at: Option, pub expires_at: Option, @@ -448,7 +522,8 @@ pub struct ProfileMembershipResponse { pub cycle_index: u32, /// 本次购买总期数(月付 1 / 年付 12)。 pub cycle_count: u32, - /// 周期 token:`monthly` / `yearly`。 + /// 周期 token:`monthly` / `yearly`;未知取值按 `yearly` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_membership_cycle_kind")] pub cycle_kind: ProfileMembershipCycleKindToken, } @@ -842,9 +917,15 @@ pub struct ProfileMembershipUpgradeQuoteRequest { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileMembershipUpgradeQuoteResponse { + /// 当前档位 token;未知取值按 `max` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_membership_plan")] pub current_plan: ProfileMembershipPlanToken, + /// 目标档位 token;未知取值按 `max` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_membership_plan")] pub target_plan: ProfileMembershipPlanToken, - /// 周期 token:`monthly` / `yearly`;有效期内不支持月年互换,所以变更前后一致。 + /// 周期 token:`monthly` / `yearly`;有效期内不支持月年互换,所以变更前后一致; + /// 未知取值按 `yearly` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_membership_cycle_kind")] pub cycle_kind: ProfileMembershipCycleKindToken, pub cycle_index: u32, pub cycle_count: u32, @@ -2171,4 +2252,64 @@ mod tests { json!("treasure_reward") ); } + + #[test] + fn membership_response_tokens_tolerate_unknown_values_from_newer_backend() { + // 旧 Rust 客户端(AGC Tauri shell)反序列化响应时,遇到后端新增的 token 取值 + // 不能整包失败;未知取值按 fail-open 兜底到最高已知档。 + let membership: ProfileMembershipResponse = serde_json::from_value(json!({ + "status": "paused", + "plan": "ultra", + "startedAt": null, + "expiresAt": null, + "updatedAt": null, + "cycleStartedAt": null, + "cycleResetsAt": null, + "cycleGrantedPoints": 0, + "cycleRemainingPoints": 0, + "cycleIndex": 1, + "cycleCount": 1, + "cycleKind": "quarterly" + })) + .expect("unknown membership tokens must not fail the whole response"); + assert_eq!(membership.status, ProfileMembershipStatusToken::Active); + assert_eq!(membership.plan, ProfileMembershipPlanToken::Max); + assert_eq!( + membership.cycle_kind, + ProfileMembershipCycleKindToken::Yearly + ); + + let plan: ProfileMembershipPlanResponse = serde_json::from_value(json!({ + "plan": "ultra", + "title": "Ultra", + "rank": 9, + "monthPriceCents": 0, + "yearPriceCents": 0, + "periodPoints": 0, + "modelAccess": "unlimited", + "concurrentJobLimit": 128, + "recommended": false, + "unlimitedConcurrency": true, + "enabled": true + })) + .expect("unknown plan tokens must not fail the whole response"); + assert_eq!(plan.plan, ProfileMembershipPlanToken::Max); + assert_eq!(plan.model_access, ProfileMembershipModelAccessToken::Full); + + // 已知取值仍然原样解析(兜底不吞掉合法值)。 + let known: ProfileMembershipStatusToken = + serde_json::from_value(json!("normal")).expect("known token should deserialize"); + assert_eq!(known, ProfileMembershipStatusToken::Normal); + } + + #[test] + fn membership_request_tokens_stay_strict_for_unknown_values() { + // 入参 DTO 不做兜底:非法目标档位必须在反序列化即被拒绝。 + assert!( + serde_json::from_value::(json!({ + "targetPlan": "ultra" + })) + .is_err() + ); + } } From 0930e402be8eed3cc5aed09911735196bdca5eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 15:32:19 +0800 Subject: [PATCH 206/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=A0=81):=20=E4=BF=AE=E6=AD=A3=20ProductIdU?= =?UTF-8?q?nparseable=20=E6=8B=BC=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module-runtime 变体 ProductIdUnparsable 改名为 ProductIdUnparseable,wire 串 product_id_unparsable 同步改为 product_id_unparseable(本 PR 未上线,不留兼容) - 同步 spacetime-module 拒绝点、spacetime-client mapper 与 api-server 测试引用,并重新生成 SpacetimeDB 绑定 - 同步设计文档 §5.8 与决策日志 --- docs/project-memory/shared-memory/decision-log.md | 9 ++++++++- .../【技术设计】泥点三池与会员计费后端设计-2026-10-02.md | 2 +- server-rs/crates/api-server/src/runtime_profile.rs | 2 +- server-rs/crates/module-runtime/src/domain.rs | 4 ++-- .../src/active/mapper/runtime_profile.rs | 4 ++-- .../runtime_profile_membership_error_code_type.rs | 2 +- .../spacetime-module/src/runtime/active/profile.rs | 4 ++-- 7 files changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9fe895771..13dc664d9 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9687,7 +9687,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-10-03 会员链路错误分类改为机器可读错误码(typed error) - 背景:`api-server/src/runtime_profile.rs` 的 `is_runtime_profile_membership_domain_error` 用中文 `error_message` 前缀 / 精确匹配决定返回 400 还是 502;文案一改(或新增拒绝点)就静默退化,且分类逻辑与 module 的错误文案跨层重复。 -- 决策(错误码随 procedure 结果透传):`module-runtime` 新增 `RuntimeProfileMembershipErrorCode`(`UpgradeRejected` / `NotMember` / `AlreadyActive` / `CycleKindLocked` / `MembershipExpired` / `StateChanged` / `PlanNotPurchasable` / `PlanCatalogMissing` / `ProductIdUnparsable` / `MembershipProductRetired` / `InvalidPlanConfig`)与内部 `RuntimeProfileMembershipDomainError { code, message }`;三个会员 procedure 结果(升级报价、充值中心 / 下单、后台档位 upsert)末尾追加 `error_code: Option<...>`,拒绝点显式赋码,不再做字符串分类。 +- 决策(错误码随 procedure 结果透传):`module-runtime` 新增 `RuntimeProfileMembershipErrorCode`(`UpgradeRejected` / `NotMember` / `AlreadyActive` / `CycleKindLocked` / `MembershipExpired` / `StateChanged` / `PlanNotPurchasable` / `PlanCatalogMissing` / `ProductIdUnparseable` / `MembershipProductRetired` / `InvalidPlanConfig`)与内部 `RuntimeProfileMembershipDomainError { code, message }`;三个会员 procedure 结果(升级报价、充值中心 / 下单、后台档位 upsert)末尾追加 `error_code: Option<...>`,拒绝点显式赋码,不再做字符串分类。 - 决策(客户端错误类型):`spacetime-client` 新增 `SpacetimeClientError::ProcedureRejected { code, message }`;`api-server` 据此返回 400(provider `runtime-profile`),基础设施错误保持 502。字段校验类错误不产码,仍落 502。 - 决策(行为变化):`会员目录缺少档位 ...` 与「已迁移的会员商品」原先落 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、本文件。 @@ -9750,3 +9750,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:只给**响应** DTO 的 token 字段挂 `#[serde(deserialize_with = "...")]` 做前向兼容,未知取值 fail-open 兜底到已知最高档(plan→`max`、status→`active`、cycle→`yearly`、model_access→`full`;真实权益 / 下单金额以后端重算为准,避免把付费会员显示成非会员后引导重复购买)。**入参** DTO 不改,非法 token 仍在反序列化即拒;不改枚举本身的 `Deserialize`,避免 `Unknown` 变体污染入参与穷尽 match。 - 影响范围:`server-rs/crates/shared-contracts/src/runtime.rs`(4 个 helper + `ProfileMembershipPlanResponse` / `ProfileMembershipResponse` / `ProfileMembershipUpgradeQuoteResponse` 的 8 个字段)、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §2.5。后台 DTO(`AdminProfileMembershipPayload` / `AdminMembershipOrderChangePayload`)只有 TS 消费方,不做 Rust 侧处理。 - 验证:`cargo test -p shared-contracts --lib` 106 passed(含 `membership_response_tokens_tolerate_unknown_values_from_newer_backend`、`membership_request_tokens_stay_strict_for_unknown_values`)。 + +## 2026-10-03 会员错误码 `ProductIdUnparsable` 拼写修正(未上线,直接破坏性改名) + +- 背景:`RuntimeProfileMembershipErrorCode::ProductIdUnparsable` 拼写有误,生成绑定与对外 `error.code = "product_id_unparsable"` 同错。 +- 决策:该 PR 尚未上线,不留兼容,直接改名为 `ProductIdUnparseable`,wire 串同步改为 `"product_id_unparseable"`;重新生成 SpacetimeDB 绑定。 +- 影响范围:`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/mapper/runtime_profile.rs,module_bindings/runtime_profile_membership_error_code_type.rs}`、`server-rs/crates/api-server/src/runtime_profile.rs`、设计文档 §5.8、本文件(含上文旧条目中的同名标识符同步更正)。 +- 验证:`cargo test -p module-runtime --lib` 114 passed;`cargo test -p spacetime-client` 32 passed;`cargo test -p api-server runtime_profile::tests` 43 passed;`cargo test -p spacetime-module` 271 passed。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index 49bb7b3b7..cc74ca675 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -450,7 +450,7 @@ flowchart TD `error_code` 透传。 - 会员链路:`module-runtime` 的 `RuntimeProfileMembershipErrorCode`(`UpgradeRejected` / `NotMember` / `AlreadyActive` / `CycleKindLocked` / `MembershipExpired` / `StateChanged` / - `PlanNotPurchasable` / `PlanCatalogMissing` / `ProductIdUnparsable` / + `PlanNotPurchasable` / `PlanCatalogMissing` / `ProductIdUnparseable` / `MembershipProductRetired` / `InvalidPlanConfig`)在拒绝点显式产出, `RuntimeProfileMembershipUpgradeQuoteProcedureResult` / `RuntimeProfileRechargeCenterProcedureResult` / `RuntimeProfileMembershipPlanAdminProcedureResult` diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 18ee6dd6c..833f397c0 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -2645,7 +2645,7 @@ mod tests { "会员升级被拒绝: cycle_kind_change_unsupported", ), ( - RuntimeProfileMembershipErrorCode::ProductIdUnparsable, + RuntimeProfileMembershipErrorCode::ProductIdUnparseable, "会员商品 product_id 无法解析: membership-pro-yearly", ), ( diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 2196436ce..057336e08 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1503,7 +1503,7 @@ pub enum RuntimeProfileMembershipErrorCode { /// 代码内档位目录缺少该档位。 PlanCatalogMissing, /// 会员商品 product_id 无法解析。 - ProductIdUnparsable, + ProductIdUnparseable, /// 会员商品已迁移到档位目录,旧商品不再可用。 MembershipProductRetired, /// 后台会员档位改价 / 改权益配置校验失败。 @@ -1521,7 +1521,7 @@ impl RuntimeProfileMembershipErrorCode { Self::StateChanged => "state_changed", Self::PlanNotPurchasable => "plan_not_purchasable", Self::PlanCatalogMissing => "plan_catalog_missing", - Self::ProductIdUnparsable => "product_id_unparsable", + Self::ProductIdUnparseable => "product_id_unparseable", Self::MembershipProductRetired => "membership_product_retired", Self::InvalidPlanConfig => "invalid_plan_config", } diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs index 52720ef66..b83faf90e 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs @@ -2121,8 +2121,8 @@ pub(crate) fn map_runtime_profile_membership_error_code_back( crate::module_bindings::RuntimeProfileMembershipErrorCode::PlanCatalogMissing => { module_runtime::RuntimeProfileMembershipErrorCode::PlanCatalogMissing } - crate::module_bindings::RuntimeProfileMembershipErrorCode::ProductIdUnparsable => { - module_runtime::RuntimeProfileMembershipErrorCode::ProductIdUnparsable + crate::module_bindings::RuntimeProfileMembershipErrorCode::ProductIdUnparseable => { + module_runtime::RuntimeProfileMembershipErrorCode::ProductIdUnparseable } crate::module_bindings::RuntimeProfileMembershipErrorCode::MembershipProductRetired => { module_runtime::RuntimeProfileMembershipErrorCode::MembershipProductRetired diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_error_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_error_code_type.rs index 984004de4..b5ffe9ca4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_error_code_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_error_code_type.rs @@ -24,7 +24,7 @@ pub enum RuntimeProfileMembershipErrorCode { PlanCatalogMissing, - ProductIdUnparsable, + ProductIdUnparseable, MembershipProductRetired, 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 c9df58b41..18ad8f4ed 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -10040,7 +10040,7 @@ fn build_profile_membership_order_snapshot( let (plan, cycle_kind) = parse_runtime_profile_membership_product_id(product_id).ok_or_else(|| { RuntimeProfileMembershipDomainError::new( - RuntimeProfileMembershipErrorCode::ProductIdUnparsable, + RuntimeProfileMembershipErrorCode::ProductIdUnparseable, format!("会员商品 product_id 无法解析: {product_id}"), ) })?; @@ -10678,7 +10678,7 @@ fn build_recomputed_profile_membership_settlement( let (plan, cycle_kind) = parse_runtime_profile_membership_product_id(product_id).ok_or_else(|| { RuntimeProfileMembershipDomainError::new( - RuntimeProfileMembershipErrorCode::ProductIdUnparsable, + RuntimeProfileMembershipErrorCode::ProductIdUnparseable, format!("会员商品 product_id 无法解析: {product_id}"), ) })?; From bc3e36ff3358a84cb1fca05b143e6ec7467a1192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 15:51:07 +0800 Subject: [PATCH 207/232] =?UTF-8?q?=E9=87=8D=E6=9E=84(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E5=A5=91=E7=BA=A6):=20=E4=BC=9A=E5=91=98=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E4=BD=93=E6=94=B9=E7=94=A8=20ts-rs=20=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared-contracts 为 10 个会员结构体补 ts_rs::TS / ts(export),64 位整数字段补 ts(type = "number") 对齐前端 number - 新增 10 个生成绑定;packages/shared 与 admin-web 改为 re-export 生成类型,仅 kind / status 等前端收窄联合用 Omit 组合保留 - 同步设计文档 §2.5 与决策日志 --- apps/admin-web/src/api/adminApiTypes.ts | 39 +----- .../shared-memory/decision-log.md | 7 + ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 8 ++ ...AdminUpsertProfileMembershipPlanRequest.ts | 20 +++ .../ProfileDailyFreePointsResponse.ts | 3 + .../ProfileMembershipPlanAdminListResponse.ts | 8 ++ .../ProfileMembershipPlanResponse.ts | 25 ++++ .../generated/ProfileMembershipResponse.ts | 26 ++++ .../ProfileMembershipUpgradeQuoteRequest.ts | 8 ++ .../ProfileMembershipUpgradeQuoteResponse.ts | 33 +++++ .../ProfileRechargeCenterResponse.ts | 13 ++ .../generated/ProfileRechargeOrderResponse.ts | 3 + .../ProfileRechargeProductResponse.ts | 3 + packages/shared/src/contracts/runtime.ts | 127 ++++++----------- .../crates/shared-contracts/src/runtime.rs | 129 ++++++++++++++++++ 15 files changed, 332 insertions(+), 120 deletions(-) create mode 100644 packages/shared/src/contracts/generated/AdminUpsertProfileMembershipPlanRequest.ts create mode 100644 packages/shared/src/contracts/generated/ProfileDailyFreePointsResponse.ts create mode 100644 packages/shared/src/contracts/generated/ProfileMembershipPlanAdminListResponse.ts create mode 100644 packages/shared/src/contracts/generated/ProfileMembershipPlanResponse.ts create mode 100644 packages/shared/src/contracts/generated/ProfileMembershipResponse.ts create mode 100644 packages/shared/src/contracts/generated/ProfileMembershipUpgradeQuoteRequest.ts create mode 100644 packages/shared/src/contracts/generated/ProfileMembershipUpgradeQuoteResponse.ts create mode 100644 packages/shared/src/contracts/generated/ProfileRechargeCenterResponse.ts create mode 100644 packages/shared/src/contracts/generated/ProfileRechargeOrderResponse.ts create mode 100644 packages/shared/src/contracts/generated/ProfileRechargeProductResponse.ts diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index f888d5913..aa532dc60 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -2,6 +2,7 @@ import type { ProfileMembershipCycleKind, ProfileMembershipModelAccess, ProfileMembershipPlan, + ProfileMembershipPlanResponse, ProfileMembershipStatus, } from '@genarrative/shared'; @@ -784,39 +785,13 @@ export interface ProfileRechargeProductConfigAdminListResponse { entries: ProfileRechargeProductConfigAdminResponse[]; } -/** 会员档位目录行;后端 `profile_membership_plan` 表是价格与权益的唯一真相源。 */ -export interface ProfileMembershipPlanAdminResponse { - plan: ProfileMembershipPlan; - title: string; - rank: number; - monthPriceCents: number; - yearPriceCents: number; - periodPoints: number; - modelAccess: ProfileMembershipModelAccess; - /** 并发上限;`128` 表示不设上限。 */ - concurrentJobLimit: number; - /** 是否不设并发上限;由后端按哨兵值派生,前端不再自己比较 `128`。 */ - unlimitedConcurrency: boolean; - enabled: boolean; -} +/** 会员档位目录行;直接复用 `@genarrative/shared` 的后端生成类型,避免手工漂移。 */ +export type ProfileMembershipPlanAdminResponse = ProfileMembershipPlanResponse; -export interface ProfileMembershipPlanAdminListResponse { - entries: ProfileMembershipPlanAdminResponse[]; - /** 并发哨兵阈值:`concurrentJobLimit >= 该值` 即不设上限;由后端下发,前端不硬编码。 */ - unlimitedConcurrencyThreshold: number; -} - -/** 后台按档位改价 / 改权益;`rank` 不在请求里,升级比较基准由代码内目录决定。 */ -export interface AdminUpsertProfileMembershipPlanRequest { - plan: ProfileMembershipPlan; - title: string; - monthPriceCents: number; - yearPriceCents: number; - periodPoints: number; - modelAccess: ProfileMembershipModelAccess; - concurrentJobLimit: number; - enabled: boolean; -} +export type { + AdminUpsertProfileMembershipPlanRequest, + ProfileMembershipPlanAdminListResponse, +} from '@genarrative/shared'; export interface ProfileWalletConfigAdminResponse { configId: string; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 13dc664d9..46ba3d5af 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9757,3 +9757,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:该 PR 尚未上线,不留兼容,直接改名为 `ProductIdUnparseable`,wire 串同步改为 `"product_id_unparseable"`;重新生成 SpacetimeDB 绑定。 - 影响范围:`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/mapper/runtime_profile.rs,module_bindings/runtime_profile_membership_error_code_type.rs}`、`server-rs/crates/api-server/src/runtime_profile.rs`、设计文档 §5.8、本文件(含上文旧条目中的同名标识符同步更正)。 - 验证:`cargo test -p module-runtime --lib` 114 passed;`cargo test -p spacetime-client` 32 passed;`cargo test -p api-server runtime_profile::tests` 43 passed;`cargo test -p spacetime-module` 271 passed。 + +## 2026-10-03 会员结构体也走 ts-rs 生成类型(去前端手写漂移) + +- 背景:会员 token 枚举早已 `ts(export)`,但承载它们的结构体(`ProfileMembershipPlanResponse`、`ProfileMembershipResponse`、`ProfileRechargeCenterResponse`、`ProfileRechargeProductResponse`、`ProfileRechargeOrderResponse`、`ProfileMembershipUpgradeQuoteResponse`、`ProfileMembershipPlanAdminListResponse`、`AdminUpsertProfileMembershipPlanRequest`、`ProfileDailyFreePointsResponse`、`ProfileMembershipUpgradeQuoteRequest`)仍是前端手写,字段与后端可能漂移。 +- 决策:这 10 个结构体补齐 `ts_rs::TS` + `ts(export)`;64 位整数字段统一补 `ts(type = "number")`(ts-rs 默认 `bigint`,JSON 实际是 number)。`packages/shared/src/contracts/runtime.ts` 与 `apps/admin-web/src/api/adminApiTypes.ts` 改为 re-export 生成类型,仅前端需要收窄的 `kind` / `status` 联合用 `Omit` 组合保留;`ProfileRechargeCenterResponse` 的 `pointProducts` / `latestOrder` 同样收窄。 +- 影响范围:`server-rs/crates/shared-contracts/src/runtime.rs`、`packages/shared/src/contracts/generated/*`(新增 10 个文件)、`packages/shared/src/contracts/runtime.ts`、`apps/admin-web/src/api/adminApiTypes.ts`、设计文档 §2.5。 +- 验证:`npm run check:generated-bindings`(shared-contracts 23 个文件);根 `npm run typecheck`、`apps/admin-web` 与 AGC shell 主 `tsc` 通过;`cargo test -p shared-contracts --lib` 106 passed;`PlatformProfileRechargeModal` / `usePlatformProfileCenterController` vitest 14 passed。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index cc74ca675..3d1abaf46 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -160,6 +160,14 @@ pub struct ProfileMembershipPlan { 避免 AGC Tauri shell 等 Rust 客户端因后端新增一个 token 就整包解析失败(权益与下单金额仍以后端重算为准)。 这 5 个 token 枚举统一 `#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]` 导出到 `packages/shared/src/contracts/generated/`,前端只 re-export,不再手写字符串联合类型。 +- **会员结构体也走生成类型(2026-10-03 补)**:`ProfileMembershipPlanResponse` / `ProfileMembershipResponse` / + `ProfileRechargeProductResponse` / `ProfileRechargeOrderResponse` / `ProfileRechargeCenterResponse` / + `ProfileMembershipUpgradeQuoteResponse` / `ProfileMembershipPlanAdminListResponse` / + `AdminUpsertProfileMembershipPlanRequest` / `ProfileDailyFreePointsResponse` / + `ProfileMembershipUpgradeQuoteRequest` 同样 `ts(export)`;64 位整数字段补 `ts(type = "number")` + 对齐前端 `number`(ts-rs 默认 `bigint`)。`packages/shared/src/contracts/runtime.ts` 与 + `apps/admin-web/src/api/adminApiTypes.ts` 改为 re-export 生成类型,只手写保留 `kind` / `status` + 这类前端收窄联合(用 `Omit` 组合),`ProfileRechargeCenterResponse` 同理收窄内层商品 / 订单数组。 - **删除** `RuntimeProfileMembershipTier { Normal, Month, Season, Year, Starter, Basic, Pro, Ultimate }` 及其 `as_str()`(会员侧零存量,用户已授权破坏性变更)。 - `RuntimeProfileWalletLedgerSourceType` **末尾追加** `MembershipUpgradeGrant` diff --git a/packages/shared/src/contracts/generated/AdminUpsertProfileMembershipPlanRequest.ts b/packages/shared/src/contracts/generated/AdminUpsertProfileMembershipPlanRequest.ts new file mode 100644 index 000000000..e1cbc53ab --- /dev/null +++ b/packages/shared/src/contracts/generated/AdminUpsertProfileMembershipPlanRequest.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProfileMembershipModelAccessToken } from "./ProfileMembershipModelAccessToken"; +import type { ProfileMembershipPlanToken } from "./ProfileMembershipPlanToken"; + +/** + * 后台按档位改会员价格与权益;`rank` 不在请求里,升级比较基准由代码内目录决定。 + */ +export type AdminUpsertProfileMembershipPlanRequest = { +/** + * 档位 token,作为主键即档位身份。 + */ +plan: ProfileMembershipPlanToken, title: string, monthPriceCents: number, yearPriceCents: number, periodPoints: number, +/** + * 模型权限 token:`basic` / `full`;本期只落字段、不做服务端拦截。 + */ +modelAccess: ProfileMembershipModelAccessToken, +/** + * 并发上限;`128` 表示不设上限。 + */ +concurrentJobLimit: number, enabled: boolean, }; diff --git a/packages/shared/src/contracts/generated/ProfileDailyFreePointsResponse.ts b/packages/shared/src/contracts/generated/ProfileDailyFreePointsResponse.ts new file mode 100644 index 000000000..398f8425b --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileDailyFreePointsResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ProfileDailyFreePointsResponse = { dayKey: number, grantedPoints: number, remainingPoints: number, resetsAt: string, updatedAt: string, }; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipPlanAdminListResponse.ts b/packages/shared/src/contracts/generated/ProfileMembershipPlanAdminListResponse.ts new file mode 100644 index 000000000..8749193c5 --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMembershipPlanAdminListResponse.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProfileMembershipPlanResponse } from "./ProfileMembershipPlanResponse"; + +export type ProfileMembershipPlanAdminListResponse = { entries: Array, +/** + * 并发哨兵阈值:`concurrent_job_limit >= 该值` 即不设上限;由后端下发,前端不硬编码。 + */ +unlimitedConcurrencyThreshold: number, }; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipPlanResponse.ts b/packages/shared/src/contracts/generated/ProfileMembershipPlanResponse.ts new file mode 100644 index 000000000..adca547ab --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMembershipPlanResponse.ts @@ -0,0 +1,25 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProfileMembershipModelAccessToken } from "./ProfileMembershipModelAccessToken"; +import type { ProfileMembershipPlanToken } from "./ProfileMembershipPlanToken"; + +export type ProfileMembershipPlanResponse = { +/** + * 档位 token:`normal` / `starter` / `plus` / `pro` / `max`;未知取值按 `max` 兜底。 + */ +plan: ProfileMembershipPlanToken, title: string, rank: number, monthPriceCents: number, yearPriceCents: number, periodPoints: number, +/** + * 模型权限 token:`basic` / `full`;未知取值按 `full` 兜底。 + */ +modelAccess: ProfileMembershipModelAccessToken, +/** + * 并发上限;`128` 表示不设上限。 + */ +concurrentJobLimit: number, +/** + * 是否为运营推荐档位;由后端目录决定,前端不再硬编码档位 token。 + */ +recommended: boolean, +/** + * 是否不设并发上限;由后端按哨兵值派生,前端不再硬编码 `128`。 + */ +unlimitedConcurrency: boolean, enabled: boolean, }; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipResponse.ts b/packages/shared/src/contracts/generated/ProfileMembershipResponse.ts new file mode 100644 index 000000000..fa5fd8db4 --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMembershipResponse.ts @@ -0,0 +1,26 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProfileMembershipCycleKindToken } from "./ProfileMembershipCycleKindToken"; +import type { ProfileMembershipPlanToken } from "./ProfileMembershipPlanToken"; +import type { ProfileMembershipStatusToken } from "./ProfileMembershipStatusToken"; + +export type ProfileMembershipResponse = { +/** + * 会员状态 token:`normal` / `active`;未知取值按 `active` 兜底。 + */ +status: ProfileMembershipStatusToken, +/** + * 当前档位 token;非会员与已到期都是 `normal`;未知取值按 `max` 兜底。 + */ +plan: ProfileMembershipPlanToken, startedAt: string | null, expiresAt: string | null, updatedAt: string | null, cycleStartedAt: string | null, cycleResetsAt: string | null, cycleGrantedPoints: number, cycleRemainingPoints: number, +/** + * 当前期序号(1-based)。 + */ +cycleIndex: number, +/** + * 本次购买总期数(月付 1 / 年付 12)。 + */ +cycleCount: number, +/** + * 周期 token:`monthly` / `yearly`;未知取值按 `yearly` 兜底。 + */ +cycleKind: ProfileMembershipCycleKindToken, }; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipUpgradeQuoteRequest.ts b/packages/shared/src/contracts/generated/ProfileMembershipUpgradeQuoteRequest.ts new file mode 100644 index 000000000..785ffdd1f --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMembershipUpgradeQuoteRequest.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProfileMembershipPlanToken } from "./ProfileMembershipPlanToken"; + +export type ProfileMembershipUpgradeQuoteRequest = { +/** + * 目标档位 token:`starter` / `plus` / `pro` / `max`。 + */ +targetPlan: ProfileMembershipPlanToken, }; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipUpgradeQuoteResponse.ts b/packages/shared/src/contracts/generated/ProfileMembershipUpgradeQuoteResponse.ts new file mode 100644 index 000000000..9301658ab --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMembershipUpgradeQuoteResponse.ts @@ -0,0 +1,33 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProfileMembershipCycleKindToken } from "./ProfileMembershipCycleKindToken"; +import type { ProfileMembershipPlanToken } from "./ProfileMembershipPlanToken"; + +/** + * 补差升级报价:付款前展示应付金额、补点与变更后的月度余额;下单时后端会用同一函数重算。 + */ +export type ProfileMembershipUpgradeQuoteResponse = { +/** + * 当前档位 token;未知取值按 `max` 兜底。 + */ +currentPlan: ProfileMembershipPlanToken, +/** + * 目标档位 token;未知取值按 `max` 兜底。 + */ +targetPlan: ProfileMembershipPlanToken, +/** + * 周期 token:`monthly` / `yearly`;有效期内不支持月年互换,所以变更前后一致; + * 未知取值按 `yearly` 兜底。 + */ +cycleKind: ProfileMembershipCycleKindToken, cycleIndex: number, cycleCount: number, +/** + * 应付金额(分);月付 = 月价差,年付 = 年价差 ÷ 12 × (后续完整月数 + 本期剩余比例),向上取整到分。 + */ +amountCents: number, +/** + * 本期补点;年付按本期剩余比例向下取整。 + */ +grantedPointsDelta: number, +/** + * 变更后月度余额 = 原剩余月度余额 + 补点(已用点不返还,永久泥点不动)。 + */ +monthlyBalanceAfter: number, expiresAt: string | null, cycleResetsAt: string | null, remainingFullMonths: number, }; diff --git a/packages/shared/src/contracts/generated/ProfileRechargeCenterResponse.ts b/packages/shared/src/contracts/generated/ProfileRechargeCenterResponse.ts new file mode 100644 index 000000000..4eb1a0a85 --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileRechargeCenterResponse.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProfileDailyFreePointsResponse } from "./ProfileDailyFreePointsResponse"; +import type { ProfileMembershipPlanResponse } from "./ProfileMembershipPlanResponse"; +import type { ProfileMembershipResponse } from "./ProfileMembershipResponse"; +import type { ProfileMudPointBalanceResponse } from "./ProfileMudPointBalanceResponse"; +import type { ProfileRechargeOrderResponse } from "./ProfileRechargeOrderResponse"; +import type { ProfileRechargeProductResponse } from "./ProfileRechargeProductResponse"; + +export type ProfileRechargeCenterResponse = { mudPointBalance: ProfileMudPointBalanceResponse, membership: ProfileMembershipResponse, pointProducts: Array, +/** + * 会员档位目录(后台可改价),会员购买 / 升级的价格与权益都以它为准。 + */ +membershipPlans: Array, latestOrder: ProfileRechargeOrderResponse | null, hasPointsRecharged: boolean, dailyFreePoints: ProfileDailyFreePointsResponse, }; diff --git a/packages/shared/src/contracts/generated/ProfileRechargeOrderResponse.ts b/packages/shared/src/contracts/generated/ProfileRechargeOrderResponse.ts new file mode 100644 index 000000000..7178fb1de --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileRechargeOrderResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ProfileRechargeOrderResponse = { orderId: string, productId: string, productTitle: string, kind: string, amountCents: number, status: string, paymentChannel: string, paidAt: string | null, providerTransactionId: string | null, createdAt: string, pointsDelta: number, membershipExpiresAt: string | null, expiredAt: string | null, expirationCheckedAt: string | null, expirationProviderState: string | null, expirationLastError: string | null, }; diff --git a/packages/shared/src/contracts/generated/ProfileRechargeProductResponse.ts b/packages/shared/src/contracts/generated/ProfileRechargeProductResponse.ts new file mode 100644 index 000000000..84f522479 --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileRechargeProductResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ProfileRechargeProductResponse = { productId: string, title: string, priceCents: number, kind: string, pointsAmount: number, bonusPoints: number, durationDays: number, badgeLabel: string, description: string, }; diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index d8bafe63e..e00ad4071 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -1,18 +1,37 @@ import type { JsonObject } from './common'; +import type { AdminUpsertProfileMembershipPlanRequest } from './generated/AdminUpsertProfileMembershipPlanRequest'; +import type { ProfileDailyFreePointsResponse } from './generated/ProfileDailyFreePointsResponse'; import type { ProfileMembershipChangeKindToken } from './generated/ProfileMembershipChangeKindToken'; import type { ProfileMembershipCycleKindToken } from './generated/ProfileMembershipCycleKindToken'; import type { ProfileMembershipModelAccessToken } from './generated/ProfileMembershipModelAccessToken'; +import type { ProfileMembershipPlanAdminListResponse } from './generated/ProfileMembershipPlanAdminListResponse'; +import type { ProfileMembershipPlanResponse } from './generated/ProfileMembershipPlanResponse'; import type { ProfileMembershipPlanToken } from './generated/ProfileMembershipPlanToken'; +import type { ProfileMembershipResponse } from './generated/ProfileMembershipResponse'; import type { ProfileMembershipStatusToken } from './generated/ProfileMembershipStatusToken'; +import type { ProfileMembershipUpgradeQuoteRequest } from './generated/ProfileMembershipUpgradeQuoteRequest'; +import type { ProfileMembershipUpgradeQuoteResponse } from './generated/ProfileMembershipUpgradeQuoteResponse'; import type { ProfileMudPointBalanceResponse as ProfileMudPointBalance } from './generated/ProfileMudPointBalanceResponse'; +import type { ProfileRechargeCenterResponse as GeneratedProfileRechargeCenterResponse } from './generated/ProfileRechargeCenterResponse'; +import type { ProfileRechargeOrderResponse } from './generated/ProfileRechargeOrderResponse'; +import type { ProfileRechargeProductResponse } from './generated/ProfileRechargeProductResponse'; export type { + AdminUpsertProfileMembershipPlanRequest, + ProfileDailyFreePointsResponse, ProfileMembershipChangeKindToken, ProfileMembershipCycleKindToken, ProfileMembershipModelAccessToken, + ProfileMembershipPlanAdminListResponse, + ProfileMembershipPlanResponse, ProfileMembershipPlanToken, + ProfileMembershipResponse, ProfileMembershipStatusToken, + ProfileMembershipUpgradeQuoteRequest, + ProfileMembershipUpgradeQuoteResponse, ProfileMudPointBalance, + ProfileRechargeOrderResponse, + ProfileRechargeProductResponse, }; export const SAVE_SNAPSHOT_VERSION = 2; @@ -69,13 +88,7 @@ export type ProfileDashboardSummary = { dailyFreePoints?: ProfileDailyFreePoints; }; -export type ProfileDailyFreePoints = { - dayKey: number; - grantedPoints: number; - remainingPoints: number; - resetsAt: string; - updatedAt: string; -}; +export type ProfileDailyFreePoints = ProfileDailyFreePointsResponse; export type ProfileWalletLedgerEntry = { id: string; @@ -148,101 +161,39 @@ export type ProfileRechargeOrderStatus = | 'refunded' | 'expired'; -export type ProfileRechargeProduct = { - productId: string; - title: string; - priceCents: number; +/** 充点 / 会员商品;`kind` 是前端收窄的联合类型,其余字段以后端生成为准。 */ +export type ProfileRechargeProduct = Omit< + ProfileRechargeProductResponse, + 'kind' +> & { kind: ProfileRechargeProductKind; - pointsAmount: number; - bonusPoints: number; - durationDays: number; - badgeLabel: string; - description: string; }; /** 会员档位目录行;价格与权益的唯一真相源是后端 `profile_membership_plan` 表。 */ -export type ProfileMembershipPlanRecord = { - plan: ProfileMembershipPlan; - title: string; - rank: number; - monthPriceCents: number; - yearPriceCents: number; - periodPoints: number; - modelAccess: ProfileMembershipModelAccess; - /** 并发上限;`128` 表示不设上限。 */ - concurrentJobLimit: number; - /** 是否为运营推荐档位;由后端目录给出,前端不再硬编码档位 token。 */ - recommended: boolean; - /** 是否不设并发上限;由后端按哨兵值派生,前端不再硬编码 `128`。 */ - unlimitedConcurrency: boolean; - enabled: boolean; -}; +export type ProfileMembershipPlanRecord = ProfileMembershipPlanResponse; -export type ProfileMembership = { - status: ProfileMembershipStatus; - /** 当前档位;非会员与已到期都是 `normal`。 */ - plan: ProfileMembershipPlan; - startedAt: string | null; - expiresAt: string | null; - updatedAt: string | null; - cycleStartedAt: string | null; - cycleResetsAt: string | null; - cycleGrantedPoints: number; - cycleRemainingPoints: number; - /** 当前期序号(1-based)。 */ - cycleIndex: number; - /** 本次购买总期数(月付 1 / 年付 12)。 */ - cycleCount: number; - cycleKind: ProfileMembershipCycleKind; -}; +export type ProfileMembership = ProfileMembershipResponse; /** 补差升级报价;下单时后端会用同一套规则重算,前端展示的数字不是真相源。 */ -export type ProfileMembershipUpgradeQuote = { - currentPlan: ProfileMembershipPlan; - targetPlan: ProfileMembershipPlan; - cycleKind: ProfileMembershipCycleKind; - cycleIndex: number; - cycleCount: number; - /** 应付金额(分)。 */ - amountCents: number; - /** 本期补点。 */ - grantedPointsDelta: number; - /** 变更后月度余额 = 原剩余月度余额 + 补点。 */ - monthlyBalanceAfter: number; - expiresAt: string | null; - cycleResetsAt: string | null; - remainingFullMonths: number; -}; +export type ProfileMembershipUpgradeQuote = + ProfileMembershipUpgradeQuoteResponse; -export type ProfileRechargeOrder = { - orderId: string; - productId: string; - productTitle: string; +/** 充值订单;`kind` / `status` 是前端收窄的联合类型,其余字段以后端生成为准。 */ +export type ProfileRechargeOrder = Omit< + ProfileRechargeOrderResponse, + 'kind' | 'status' +> & { kind: ProfileRechargeProductKind; - amountCents: number; status: ProfileRechargeOrderStatus; - paymentChannel: string; - paidAt: string | null; - providerTransactionId: string | null; - createdAt: string; - pointsDelta: number; - membershipExpiresAt: string | null; - expiredAt?: string | null; - expirationCheckedAt?: string | null; - expirationProviderState?: string | null; - expirationLastError?: string | null; }; -export type ProfileRechargeCenterResponse = { - // TODO mirror backend: should not nullable - mudPointBalance?: ProfileMudPointBalance; - membership: ProfileMembership; +/** 充值中心;结构以后端生成为准,仅把商品 / 订单收窄成带联合类型的本地别名。 */ +export type ProfileRechargeCenterResponse = Omit< + GeneratedProfileRechargeCenterResponse, + 'pointProducts' | 'latestOrder' +> & { pointProducts: ProfileRechargeProduct[]; - /** 会员档位目录(后台可改价);会员购买 / 升级的价格与权益以它为准。 */ - membershipPlans: ProfileMembershipPlanRecord[]; latestOrder: ProfileRechargeOrder | null; - hasPointsRecharged: boolean; - dailyFreePoints?: ProfileDailyFreePoints; }; export type WechatMiniProgramPayParams = { diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 927446144..03a7bc41b 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -191,9 +191,23 @@ pub struct ProfileDashboardSummaryResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub struct ProfileDailyFreePointsResponse { + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub day_key: i64, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub granted_points: u64, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub remaining_points: u64, pub resets_at: String, pub updated_at: String, @@ -467,12 +481,26 @@ where #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub struct ProfileRechargeProductResponse { pub product_id: String, pub title: String, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub price_cents: u64, pub kind: String, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub points_amount: u64, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub bonus_points: u64, pub duration_days: u32, pub badge_label: String, @@ -481,14 +509,28 @@ pub struct ProfileRechargeProductResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub struct ProfileMembershipPlanResponse { /// 档位 token:`normal` / `starter` / `plus` / `pro` / `max`;未知取值按 `max` 兜底。 #[serde(deserialize_with = "deserialize_forward_compat_membership_plan")] pub plan: ProfileMembershipPlanToken, pub title: String, pub rank: u8, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub month_price_cents: u64, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub year_price_cents: u64, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub period_points: u64, /// 模型权限 token:`basic` / `full`;未知取值按 `full` 兜底。 #[serde(deserialize_with = "deserialize_forward_compat_model_access")] @@ -504,6 +546,17 @@ pub struct ProfileMembershipPlanResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub struct ProfileMembershipResponse { /// 会员状态 token:`normal` / `active`;未知取值按 `active` 兜底。 #[serde(deserialize_with = "deserialize_forward_compat_membership_status")] @@ -516,7 +569,9 @@ pub struct ProfileMembershipResponse { pub updated_at: Option, pub cycle_started_at: Option, pub cycle_resets_at: Option, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub cycle_granted_points: u64, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub cycle_remaining_points: u64, /// 当前期序号(1-based)。 pub cycle_index: u32, @@ -529,17 +584,30 @@ pub struct ProfileMembershipResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub struct ProfileRechargeOrderResponse { pub order_id: String, pub product_id: String, pub product_title: String, pub kind: String, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub amount_cents: u64, pub status: String, pub payment_channel: String, pub paid_at: Option, pub provider_transaction_id: Option, pub created_at: String, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub points_delta: i64, pub membership_expires_at: Option, pub expired_at: Option, @@ -591,6 +659,17 @@ pub struct WechatNativePaymentResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub struct ProfileRechargeCenterResponse { pub mud_point_balance: ProfileMudPointBalanceResponse, pub membership: ProfileMembershipResponse, @@ -908,6 +987,17 @@ pub struct AdminUpsertProfileRechargeProductRequest { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub struct ProfileMembershipUpgradeQuoteRequest { /// 目标档位 token:`starter` / `plus` / `pro` / `max`。 pub target_plan: ProfileMembershipPlanToken, @@ -916,6 +1006,17 @@ pub struct ProfileMembershipUpgradeQuoteRequest { /// 补差升级报价:付款前展示应付金额、补点与变更后的月度余额;下单时后端会用同一函数重算。 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub struct ProfileMembershipUpgradeQuoteResponse { /// 当前档位 token;未知取值按 `max` 兜底。 #[serde(deserialize_with = "deserialize_forward_compat_membership_plan")] @@ -930,10 +1031,13 @@ pub struct ProfileMembershipUpgradeQuoteResponse { pub cycle_index: u32, pub cycle_count: u32, /// 应付金额(分);月付 = 月价差,年付 = 年价差 ÷ 12 × (后续完整月数 + 本期剩余比例),向上取整到分。 + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub amount_cents: u64, /// 本期补点;年付按本期剩余比例向下取整。 + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub granted_points_delta: u64, /// 变更后月度余额 = 原剩余月度余额 + 补点(已用点不返还,永久泥点不动)。 + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub monthly_balance_after: u64, pub expires_at: Option, pub cycle_resets_at: Option, @@ -942,6 +1046,17 @@ pub struct ProfileMembershipUpgradeQuoteResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub struct ProfileMembershipPlanAdminListResponse { pub entries: Vec, /// 并发哨兵阈值:`concurrent_job_limit >= 该值` 即不设上限;由后端下发,前端不硬编码。 @@ -951,12 +1066,26 @@ pub struct ProfileMembershipPlanAdminListResponse { /// 后台按档位改会员价格与权益;`rank` 不在请求里,升级比较基准由代码内目录决定。 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub struct AdminUpsertProfileMembershipPlanRequest { /// 档位 token,作为主键即档位身份。 pub plan: ProfileMembershipPlanToken, pub title: String, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub month_price_cents: u64, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub year_price_cents: u64, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub period_points: u64, /// 模型权限 token:`basic` / `full`;本期只落字段、不做服务端拦截。 pub model_access: ProfileMembershipModelAccessToken, From cebffb9dadcee82a41f443919a1b5761b86a3f1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 17:28:38 +0800 Subject: [PATCH 208/232] =?UTF-8?q?=E6=B5=8B=E8=AF=95(AGC=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B):=20=E8=A1=A5=E9=BD=90=E8=A3=B8=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=85=BC=E5=AE=B9=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 legacy_raw_catalog_payload_defaults_optional_fields:覆盖无 ok 信封、缺少 agentMode/protocol/unavailableModels 的旧裸目录响应 - 断言缺省按 Codex + OpenAI Responses + 空不可用桶兜底,锁定既有兼容行为 --- .../src-tauri/src/llm_catalog.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs index 620701eb0..eb2d75db9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs @@ -290,6 +290,32 @@ mod tests { ); } + #[test] + fn legacy_raw_catalog_payload_defaults_optional_fields() { + // 旧 dev 代理 / 本地 fixture 直接返回裸目录(没有 `ok` 信封),且缺少 + // `agentMode` / `protocol` / `unavailableModels`:必须仍能解析,并按 Codex + + // OpenAI Responses + 空不可用桶兼容。 + let body = serde_json::json!({ + "defaultModelId": "quality", + "models": [{ "id": "quality", "displayName": "高质量" }], + "revision": 9 + }) + .to_string(); + + let catalog = parse_catalog_payload(body.as_bytes()).expect("raw catalog should parse"); + assert_eq!(catalog.default_model_id, "quality"); + assert_eq!(catalog.revision, 9); + assert!(catalog.unavailable_models.is_empty()); + assert_eq!( + catalog.models[0].agent_mode, + shared_contracts::llm::AgcAgentMode::Codex + ); + assert_eq!( + catalog.models[0].protocol, + shared_contracts::llm::AgcModelProtocol::OpenAiResponses + ); + } + #[tokio::test] async fn official_catalog_fails_closed_on_http_error_without_accepting_error_as_data() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture"); From 2ac445fe1135785492b864995c978739e04cde34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 17:30:18 +0800 Subject: [PATCH 209/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=81=87=E6=8E=A5=E5=8F=A3):=20=E7=94=A8=E6=88=B7=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=92=B1=E5=8C=85=E5=A4=B9=E5=85=B7=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=20AdminProfileWalletPayload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - userDetail.wallet 去掉已删除的 balance 字段,补齐 totalBalance/dailyFreePoints/monthlyPoints/refundDebtPoints/userId - 与 recharge-orders 路由的 wallet helper、AdminUserDetailDialog 的读取字段保持一致,避免本地预览显示 undefined --- scripts/admin-web-fake-api.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/admin-web-fake-api.mjs b/scripts/admin-web-fake-api.mjs index b2355f662..59f3e8e30 100644 --- a/scripts/admin-web-fake-api.mjs +++ b/scripts/admin-web-fake-api.mjs @@ -454,10 +454,14 @@ const userDetail = { createdAt: iso(60 * 24 * 120), lastLoginAt: iso(30), wallet: { - balance: 1860, - permanentPoints: 1200, + userId: 'u_1001', + totalBalance: 1860, spendableBalance: 1660, + dailyFreePoints: 0, + monthlyPoints: 0, + permanentPoints: 1200, heldPoints: 200, + refundDebtPoints: 0, walletFrozen: false, manualFrozen: false, refundDebtFrozen: false, From 715a4992fd32c8cae043e777b807067a09d3a4c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 17:30:55 +0800 Subject: [PATCH 210/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=81=87=E6=8E=A5=E5=8F=A3):=20=E4=BC=9A=E5=91=98=E6=A1=A3?= =?UTF-8?q?=E4=BD=8D=E5=A4=B9=E5=85=B7=E8=A1=A5=E9=BD=90=20recommended=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - membership-plans 的 GET 列表与 POST 回显补上契约必填的 recommended - 按后端 PROFILE_MEMBERSHIP_RECOMMENDED_PLAN 把 plus 标为推荐,避免本地预览与真实响应漂移 --- scripts/admin-web-fake-api.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/admin-web-fake-api.mjs b/scripts/admin-web-fake-api.mjs index 59f3e8e30..298f8dc43 100644 --- a/scripts/admin-web-fake-api.mjs +++ b/scripts/admin-web-fake-api.mjs @@ -775,6 +775,8 @@ function route(pathname, method, url, requestBody = '') { periodPoints, modelAccess, concurrentJobLimit, + // 与后端目录单一真相源 `PROFILE_MEMBERSHIP_RECOMMENDED_PLAN` 对齐:运营推荐 Plus。 + recommended: planId === 'plus', unlimitedConcurrency: concurrentJobLimit >= UNLIMITED_CONCURRENCY_THRESHOLD, enabled, From ff1e15194d47c5a84d585c815fddb43a3f467d1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 17:34:03 +0800 Subject: [PATCH 211/232] =?UTF-8?q?=E9=87=8D=E6=9E=84(=E6=B3=A5=E7=82=B9?= =?UTF-8?q?=E9=92=B1=E5=8C=85):=20=E5=8E=BB=E6=8E=89=E5=86=97=E4=BD=99?= =?UTF-8?q?=E7=9A=84=E4=BD=99=E9=A2=9D=E8=A7=86=E5=9B=BE=E8=81=94=E5=90=88?= =?UTF-8?q?=E6=88=90=E5=91=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PlatformMudPointWalletEntryProps.breakdown 收敛为 ProfileMudPointBalance | null - ProfileMudPointBalanceView 是 ProfileMudPointBalance 的扩展,联合会被 TypeScript 折叠,属于多余类型噪音 - 同步删除不再需要的 type-only import --- .../src/components/PlatformMudPointWalletEntry/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx index 97fd52512..1880bd60c 100644 --- a/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx @@ -13,14 +13,13 @@ import MUD_POINT_ICON_SRC from '../../icons/topbar-wallet.png'; import { formatMudPointCount } from '../../utils/format'; import { formatMudPointResetAt, - type ProfileMudPointBalanceView, resolveProfileMudPointBalanceView, shouldShowProfileDailyFreePool, } from '../../utils/mudPoints'; export type PlatformMudPointWalletEntryProps = { balance: number | null; - breakdown?: ProfileMudPointBalance | ProfileMudPointBalanceView | null; + breakdown?: ProfileMudPointBalance | null; isLoading?: boolean; error?: string | null; variant?: 'desktop' | 'mobile' | 'editor'; From 92a8fa1b66f64744d1ef4a8b61f07600f29d1481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 17:34:27 +0800 Subject: [PATCH 212/232] =?UTF-8?q?=E9=87=8D=E6=9E=84(=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E5=AF=BC=E5=87=BA):=20=E4=BC=9A=E5=91=98=E5=95=86=E5=93=81=20i?= =?UTF-8?q?d=20helper=20=E6=8F=90=E5=8D=87=E4=B8=BA=E5=85=AC=E5=85=B1?= =?UTF-8?q?=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/shared/src/index.ts 导出 resolveMembershipProductId - useAccountWallet 改从 @genarrative/shared 公共入口引入,去掉对弹层私有文件结构的深路径依赖 --- .../src/features/app-shell/useAccountWallet.ts | 2 +- packages/shared/src/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts index fb7e47c20..d95dabd72 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts @@ -1,7 +1,7 @@ +import { resolveMembershipProductId } from '@genarrative/shared'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { type PlatformProfileRechargeNativePaymentState } from '../../../../../packages/shared/src/components/PlatformProfileRechargeModal'; -import { resolveMembershipProductId } from '../../../../../packages/shared/src/components/PlatformProfileRechargeModal/membershipProduct'; import type { ProfileMembershipCycleKind, ProfileMembershipPlan, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ecf7a17c8..cf0fbb683 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,5 @@ export * from './components'; +export * from './components/PlatformProfileRechargeModal/membershipProduct'; export * from './contracts/auth'; export type * from './contracts/clientDownload'; export * from './contracts/common'; From 9707b74002de5fd6ce88b6151995b766412f561a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 17:35:18 +0800 Subject: [PATCH 213/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E4=BC=9A=E5=91=98=E6=A1=A3=E4=BD=8D):=20=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E6=97=B6=E6=8C=89=20rank=20=E6=8E=92?= =?UTF-8?q?=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refreshPlans 复用 sortPlanEntries,首屏顺序与保存后一致 - 避免加载态与保存态列表顺序不一致,与档位列“按升级基准 rank”的说明对齐 --- apps/admin-web/src/pages/AdminMembershipPlanPage.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx index f3692160b..75a9c3365 100644 --- a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx +++ b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx @@ -92,11 +92,12 @@ export function AdminMembershipPlanPage({ setListErrorMessage(''); try { const response = await listProfileMembershipPlans(token); - setEntries(response.entries); + // 与保存后的排序保持一致:列表按升级基准 `rank` 展示。 + const sortedEntries = sortPlanEntries(response.entries); + setEntries(sortedEntries); setUnlimitedConcurrencyThreshold(response.unlimitedConcurrencyThreshold); const firstEntry = - response.entries.find((entry) => entry.plan === plan) ?? - response.entries[0]; + sortedEntries.find((entry) => entry.plan === plan) ?? sortedEntries[0]; if (firstEntry) { fillForm(firstEntry); } From 07cf7443daa921fe3c36fdfe66655bd836a3b383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 17:37:12 +0800 Subject: [PATCH 214/232] =?UTF-8?q?=E9=87=8D=E6=9E=84(=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=A5=91=E7=BA=A6):=20=E4=BC=9A=E5=91=98=E5=8F=98=E6=9B=B4?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=A4=8D=E7=94=A8=E5=90=8E=E7=AB=AF=E7=94=9F?= =?UTF-8?q?=E6=88=90=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminMembershipOrderChangePayload.changeKind 改用 ProfileMembershipChangeKindToken - 去掉手写的 'purchase' | 'upgrade' 联合,避免与后端 token 漂移 --- apps/admin-web/src/api/adminApiTypes.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index aa532dc60..df1709dd4 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1,4 +1,5 @@ import type { + ProfileMembershipChangeKindToken, ProfileMembershipCycleKind, ProfileMembershipModelAccess, ProfileMembershipPlan, @@ -953,8 +954,8 @@ export interface AdminRechargeRefundHoldPayload { } export interface AdminMembershipOrderChangePayload { - /** `purchase`(新购)或 `upgrade`(补差升级)。 */ - changeKind: 'purchase' | 'upgrade'; + /** `purchase`(新购)或 `upgrade`(补差升级);复用后端生成 token。 */ + changeKind: ProfileMembershipChangeKindToken; planBefore?: ProfileMembershipPlan | null; planAfter?: ProfileMembershipPlan | null; cycleKindBefore?: ProfileMembershipCycleKind | null; From 7dc056651c6d8b3065451f5640b7ca44aa0910b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 17:38:01 +0800 Subject: [PATCH 215/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=AF=A6=E6=83=85):=20=E9=9D=9E=E4=BC=9A?= =?UTF-8?q?=E5=91=98=E4=B8=8D=E5=B1=95=E7=A4=BA=E5=8D=A0=E4=BD=8D=E8=B4=A6?= =?UTF-8?q?=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 账期仅在 membership.status === 'active' 时展示,否则显示 - - 避免从未订阅用户显示“月付 第 1/1 期”被误读为正在计费 --- apps/admin-web/src/components/AdminUserDetailDialog.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.tsx index c1822b965..66e31cf27 100644 --- a/apps/admin-web/src/components/AdminUserDetailDialog.tsx +++ b/apps/admin-web/src/components/AdminUserDetailDialog.tsx @@ -545,7 +545,10 @@ function MembershipSection({ ['状态', formatMembershipStatus(membership.status) || '-'], [ '账期', - `${formatMembershipCycleKind(membership.cycleKind)} 第 ${membership.cycleIndex}/${membership.cycleCount} 期`, + // 非会员时后端投影返回占位账期(monthly / 1/1),不展示成“正在计费”。 + membership.status === 'active' + ? `${formatMembershipCycleKind(membership.cycleKind)} 第 ${membership.cycleIndex}/${membership.cycleCount} 期` + : '-', ], ['本账期发放', `${membership.cycleGrantedPoints} 泥点`], ['本账期剩余', `${membership.cycleRemainingPoints} 泥点`], From 8ed716c6e2ced746ac2d4ca8cacd902d2f6607f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 17:41:26 +0800 Subject: [PATCH 216/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E5=85=85=E5=80=BC?= =?UTF-8?q?=E5=BC=B9=E5=B1=82):=20=E4=BB=BB=E4=B8=80=E5=95=86=E5=93=81?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E4=B8=AD=E5=8D=B3=E7=A6=81=E7=94=A8=E4=BC=9A?= =?UTF-8?q?=E5=91=98=E7=BB=93=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 会员结算 canSubmit 由 !submitting 改为 !submittingProductId,与充点分支的 ctaDisabled 口径一致 - 避免充点在途时仍能提交会员结算、被 buyRechargeProduct 静默丢弃后直接关闭弹层 - 补充反向用例:泥点订单提交中时会员结算按钮禁用 --- .../index.test.tsx | 44 +++++++++++++++++++ .../PlatformProfileRechargeModal/index.tsx | 3 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx index 0c15d0211..86cc808b0 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx @@ -530,6 +530,50 @@ describe('PlatformProfileRechargeModal', () => { ).toHaveProperty('disabled', true); }); + test('泥点订单提交中时会员结算入口禁用', async () => { + const user = userEvent.setup(); + const renderModal = (submittingProductId: string | null) => ( + + ); + + const { rerender } = render(renderModal(null)); + await user.click( + screen.getByRole('button', { name: 'Starter 月付 开通会员' }), + ); + + // 泥点订单提交中:会员结算 CTA 同样禁用,避免宿主守卫静默丢弃提交后直接关弹层。 + rerender(renderModal('points_60')); + expect( + within(screen.getByRole('dialog', { name: '确认订单' })).getByRole( + 'button', + { name: '支付 ¥39' }, + ), + ).toHaveProperty('disabled', true); + }); + test('shows the empty state when the recharge tab has no point product', async () => { const user = userEvent.setup(); diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index 390df4a4d..25764bb1c 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -1074,7 +1074,8 @@ function PlatformRechargeCheckoutDialog({ } const canSubmit = Boolean(plan) && - !submitting && + // 与充点分支一致:任一商品在途都禁用提交,避免静默无操作后直接关弹层。 + !submittingProductId && (!isUpgrade || Boolean(quoteForTarget)) && amountCents > 0; From 8009a70bb12d95cb528972bd051c1fce3f4ce893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 17:42:46 +0800 Subject: [PATCH 217/232] =?UTF-8?q?=E6=B5=8B=E8=AF=95(AGC=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B):=20=E7=9B=AE=E5=BD=95=E9=80=8F=E4=BC=A0=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E5=8E=BB=E6=8E=89=E8=BF=87=E6=9C=9F=E7=9A=84=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loadClientLlmModels 已改为直接透传 Rust typed command 结果 - 用例改名为透传断言,不再期望渲染层补默认 agentMode(该逻辑已下沉到 Rust) --- .../tests/clientApi.test.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/clientApi.test.ts b/apps/ai-game-creator-shell/tests/clientApi.test.ts index 8d7b8ba04..045aa9194 100644 --- a/apps/ai-game-creator-shell/tests/clientApi.test.ts +++ b/apps/ai-game-creator-shell/tests/clientApi.test.ts @@ -13,23 +13,19 @@ beforeEach(() => { vi.mocked(resolveTauriInvoke).mockReturnValue(invoke); }); -test('模型目录缺少 agentMode 时默认使用 codex,同时保留官方 cc 路由', async () => { - invoke.mockResolvedValue({ +test('模型目录直接透传 Rust typed command 的解析结果', async () => { + // agentMode / protocol 缺省已下沉到 Rust 的 load_game_creator_llm_models, + // 渲染层不再做二次归一化,这里断言原样透传即可。 + const catalog = { defaultModelId: 'quality', models: [ { id: 'quality', displayName: '高质量' }, { id: 'claude', displayName: 'Claude', agentMode: 'cc' }, ], revision: 1, - }); + }; + invoke.mockResolvedValue(catalog); - await expect(loadClientLlmModels()).resolves.toEqual({ - defaultModelId: 'quality', - models: [ - { id: 'quality', displayName: '高质量', agentMode: 'codex' }, - { id: 'claude', displayName: 'Claude', agentMode: 'cc' }, - ], - revision: 1, - }); + await expect(loadClientLlmModels()).resolves.toEqual(catalog); expect(invoke).toHaveBeenCalledWith('load_game_creator_llm_models'); }); From 759bcf98a8ea5a84324de834b7a99995ccafcb08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 17:53:23 +0800 Subject: [PATCH 218/232] =?UTF-8?q?=E6=96=87=E6=A1=A3(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E5=A5=91=E7=BA=A6):=20=E6=BE=84=E6=B8=85=20status=20=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E5=8F=AA=E4=BD=9C=E7=94=A8=E4=BA=8E=20Rust=20?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProfileMembershipResponse.status 注释改为“Rust 反序列化未知取值按 active 兜底,TS 侧不归一化” - 同步重新生成 ts-rs 绑定,避免 Web 消费方误以为前端也会 fail-open --- .../shared/src/contracts/generated/ProfileMembershipResponse.ts | 2 +- server-rs/crates/shared-contracts/src/runtime.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/contracts/generated/ProfileMembershipResponse.ts b/packages/shared/src/contracts/generated/ProfileMembershipResponse.ts index fa5fd8db4..0dd646fd0 100644 --- a/packages/shared/src/contracts/generated/ProfileMembershipResponse.ts +++ b/packages/shared/src/contracts/generated/ProfileMembershipResponse.ts @@ -5,7 +5,7 @@ import type { ProfileMembershipStatusToken } from "./ProfileMembershipStatusToke export type ProfileMembershipResponse = { /** - * 会员状态 token:`normal` / `active`;未知取值按 `active` 兜底。 + * 会员状态 token:`normal` / `active`;Rust 客户端反序列化时未知取值按 `active` 兜底,TS 侧不做运行期归一化。 */ status: ProfileMembershipStatusToken, /** diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 03a7bc41b..d04269aa8 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -558,7 +558,7 @@ pub struct ProfileMembershipPlanResponse { ) )] pub struct ProfileMembershipResponse { - /// 会员状态 token:`normal` / `active`;未知取值按 `active` 兜底。 + /// 会员状态 token:`normal` / `active`;Rust 客户端反序列化时未知取值按 `active` 兜底,TS 侧不做运行期归一化。 #[serde(deserialize_with = "deserialize_forward_compat_membership_status")] pub status: ProfileMembershipStatusToken, /// 当前档位 token;非会员与已到期都是 `normal`;未知取值按 `max` 兜底。 From 8e9d2389e0f3daafe574f80466eca8e064d5d693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 17:54:38 +0800 Subject: [PATCH 219/232] =?UTF-8?q?=E6=96=87=E6=A1=A3(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E5=A5=91=E7=BA=A6):=20=E5=B9=B6=E5=8F=91=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E7=A1=AC=E7=BC=96=E7=A0=81=20128=20=E5=93=A8?= =?UTF-8?q?=E5=85=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProfileMembershipPlanResponse.concurrentJobLimit 改为引用后端下发的 unlimitedConcurrencyThreshold - 同步重新生成 ts-rs 绑定,避免哨兵值变更时注释误导消费方 --- .../src/contracts/generated/ProfileMembershipPlanResponse.ts | 2 +- server-rs/crates/shared-contracts/src/runtime.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/contracts/generated/ProfileMembershipPlanResponse.ts b/packages/shared/src/contracts/generated/ProfileMembershipPlanResponse.ts index adca547ab..c2ba7fd73 100644 --- a/packages/shared/src/contracts/generated/ProfileMembershipPlanResponse.ts +++ b/packages/shared/src/contracts/generated/ProfileMembershipPlanResponse.ts @@ -12,7 +12,7 @@ plan: ProfileMembershipPlanToken, title: string, rank: number, monthPriceCents: */ modelAccess: ProfileMembershipModelAccessToken, /** - * 并发上限;`128` 表示不设上限。 + * 并发上限;达到后端下发的 `unlimitedConcurrencyThreshold` 即表示不设上限,前端不硬编码哨兵值。 */ concurrentJobLimit: number, /** diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index d04269aa8..f59f7018c 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -535,7 +535,7 @@ pub struct ProfileMembershipPlanResponse { /// 模型权限 token:`basic` / `full`;未知取值按 `full` 兜底。 #[serde(deserialize_with = "deserialize_forward_compat_model_access")] pub model_access: ProfileMembershipModelAccessToken, - /// 并发上限;`128` 表示不设上限。 + /// 并发上限;达到后端下发的 `unlimitedConcurrencyThreshold` 即表示不设上限,前端不硬编码哨兵值。 pub concurrent_job_limit: u32, /// 是否为运营推荐档位;由后端目录决定,前端不再硬编码档位 token。 pub recommended: bool, From 87a3794423836dcdc3834406e9a6ba4a64ee8257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 17:56:02 +0800 Subject: [PATCH 220/232] =?UTF-8?q?=E6=96=87=E6=A1=A3(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E5=A5=91=E7=BA=A6):=20=E5=90=8E=E5=8F=B0=E6=94=B9=E6=A1=A3?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E5=B9=B6=E5=8F=91=E4=B8=8A=E9=99=90=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E7=A1=AC=E7=BC=96=E7=A0=81=20128?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminUpsertProfileMembershipPlanRequest.concurrentJobLimit 改为引用后端下发的 unlimitedConcurrencyThreshold - 同步重新生成 ts-rs 绑定 --- .../generated/AdminUpsertProfileMembershipPlanRequest.ts | 2 +- server-rs/crates/shared-contracts/src/runtime.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/contracts/generated/AdminUpsertProfileMembershipPlanRequest.ts b/packages/shared/src/contracts/generated/AdminUpsertProfileMembershipPlanRequest.ts index e1cbc53ab..139a2608b 100644 --- a/packages/shared/src/contracts/generated/AdminUpsertProfileMembershipPlanRequest.ts +++ b/packages/shared/src/contracts/generated/AdminUpsertProfileMembershipPlanRequest.ts @@ -15,6 +15,6 @@ plan: ProfileMembershipPlanToken, title: string, monthPriceCents: number, yearPr */ modelAccess: ProfileMembershipModelAccessToken, /** - * 并发上限;`128` 表示不设上限。 + * 并发上限;达到后端下发的 `unlimitedConcurrencyThreshold` 即表示不设上限,前端不硬编码哨兵值。 */ concurrentJobLimit: number, enabled: boolean, }; diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index f59f7018c..8e57dd995 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -1089,7 +1089,7 @@ pub struct AdminUpsertProfileMembershipPlanRequest { pub period_points: u64, /// 模型权限 token:`basic` / `full`;本期只落字段、不做服务端拦截。 pub model_access: ProfileMembershipModelAccessToken, - /// 并发上限;`128` 表示不设上限。 + /// 并发上限;达到后端下发的 `unlimitedConcurrencyThreshold` 即表示不设上限,前端不硬编码哨兵值。 pub concurrent_job_limit: u32, #[serde(default = "default_true")] pub enabled: bool, From b8309eb3aee3f4613096e40b83f75492f0442938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 18:12:22 +0800 Subject: [PATCH 221/232] =?UTF-8?q?=E9=87=8D=E6=9E=84(=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E9=87=91=E9=A2=9D):=20=E6=8A=BD=E5=87=BA=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E7=9A=84=E5=85=83=E9=87=91=E9=A2=9D=E6=A0=BC=E5=BC=8F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared AdminFormat 新增 formatAdminCurrencyCents(分 → ¥x.xx)并从 components 导出 - AdminUserDetailDialog / AdminMembershipPlanPage / AdminRechargeOrderPage / AdminRechargeProductPage 删除各自重复的 formatMoney/formatPrice - 补充 formatAdminCurrencyCents 单测,避免后台各页面金额格式漂移 --- .../src/components/AdminUserDetailDialog.tsx | 13 ++--- .../src/pages/AdminMembershipPlanPage.tsx | 9 +-- .../src/pages/AdminRechargeOrderPage.tsx | 55 ++++++++++++++----- .../src/pages/AdminRechargeProductPage.tsx | 7 +-- .../src/components/admin/AdminFormat.test.ts | 14 ++++- .../src/components/admin/AdminFormat.ts | 5 ++ packages/shared/src/components/admin/index.ts | 6 +- 7 files changed, 74 insertions(+), 35 deletions(-) diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.tsx index 66e31cf27..f3a02184e 100644 --- a/apps/admin-web/src/components/AdminUserDetailDialog.tsx +++ b/apps/admin-web/src/components/AdminUserDetailDialog.tsx @@ -9,6 +9,7 @@ import { AdminStatusPill, AdminTag, AdminTagList, + formatAdminCurrencyCents, } from '@genarrative/shared/components'; import { RefreshCcw, ShieldAlert, UserRound, X } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; @@ -382,12 +383,14 @@ export function AdminUserDetailDialog({ {order.productTitle || order.productId} {order.paidAmountCents > 0 - ? formatMoney(order.paidAmountCents) + ? formatAdminCurrencyCents(order.paidAmountCents) : '未支付'} {order.pointsDelta} 泥点 - {formatMoney(order.cumulativeSuccessRefundCents)} + {formatAdminCurrencyCents( + order.cumulativeSuccessRefundCents, + )} 欠账 {order.unrecoveredPoints} 泥点 {formatOrderStatus(order.status)} @@ -442,7 +445,7 @@ function UserIdentityHeader({ detail }: { detail: AdminUserDetailResponse }) { value: ( <> {typeof detail.cumulativeRechargedCents === 'number' - ? formatMoney(detail.cumulativeRechargedCents) + ? formatAdminCurrencyCents(detail.cumulativeRechargedCents) : '未知'} ), @@ -580,10 +583,6 @@ function MembershipSection({ ); } -function formatMoney(cents: number) { - return `¥${(cents / 100).toFixed(2)}`; -} - function formatMicros(value: number) { if (!Number.isFinite(value) || value <= 0) { return '-'; diff --git a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx index 75a9c3365..c9198234f 100644 --- a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx +++ b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx @@ -8,6 +8,7 @@ import { AdminPage, AdminPageHeading, AdminPanel, + formatAdminCurrencyCents, } from '@genarrative/shared/components'; import { RefreshCcw, Save } from 'lucide-react'; import { FormEvent, useEffect, useState } from 'react'; @@ -410,8 +411,8 @@ export function AdminMembershipPlanPage({ - {formatPrice(entry.monthPriceCents)} /{' '} - {formatPrice(entry.yearPriceCents)} + {formatAdminCurrencyCents(entry.monthPriceCents)} /{' '} + {formatAdminCurrencyCents(entry.yearPriceCents)} {entry.periodPoints} {formatPlanLimits(entry)} @@ -460,7 +461,3 @@ function formatPlanLimits(entry: ProfileMembershipPlanAdminResponse) { : `${entry.concurrentJobLimit} 个`; return `${modelLabel} · 并发 ${concurrency}`; } - -function formatPrice(priceCents: number) { - return `¥${(priceCents / 100).toFixed(2)}`; -} diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx index efca3ed1d..1d5bf6c42 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx @@ -13,6 +13,7 @@ import { AdminPanelHeading, AdminTag, AdminTagList, + formatAdminCurrencyCents, } from '@genarrative/shared/components'; import { AlertTriangle, @@ -312,7 +313,7 @@ export function AdminRechargeOrderPage({ setRefundRequestId(requestId); const confirmed = await confirmWrite({ action: '发起微信退款并追回泥点', - target: `${refundOrder.orderId} / ${formatMoney(amountCents)}`, + target: `${refundOrder.orderId} / ${formatAdminCurrencyCents(amountCents)}`, }); if (!confirmed || submitLockRef.current) { return; @@ -721,15 +722,19 @@ export function AdminRechargeOrderPage({
预检后剩余可退, - value: <>{formatMoney(preview.remainingRefundableCents)}, + value: ( + <> + {formatAdminCurrencyCents( + preview.remainingRefundableCents, + )} + + ), }, ]} /> @@ -984,16 +995,32 @@ export function AdminRechargeOrderPage({ }, { label: <>本地订单总额, - value: <>{formatMoney(manualReviewTarget.order.amountCents)}, + value: ( + <> + {formatAdminCurrencyCents( + manualReviewTarget.order.amountCents, + )} + + ), }, { label: <>退款订单总额, - value: <>{formatMoney(manualReviewTarget.refund.totalCents)}, + value: ( + <> + {formatAdminCurrencyCents( + manualReviewTarget.refund.totalCents, + )} + + ), }, { label: <>本次退款金额, value: ( - <>{formatMoney(manualReviewTarget.refund.refundCents)} + <> + {formatAdminCurrencyCents( + manualReviewTarget.refund.refundCents, + )} + ), }, ]} @@ -1107,7 +1134,7 @@ function RechargeOrderRow({ {formatOrderPaidAmount(order)} {order.paidAmountCents > 0 ? null : ( - 订单 {formatMoney(order.amountCents)} + 订单 {formatAdminCurrencyCents(order.amountCents)} )} @@ -1117,7 +1144,9 @@ function RechargeOrderRow({ ) : null} - 累计 {formatMoney(order.cumulativeSuccessRefundCents)} + + 累计 {formatAdminCurrencyCents(order.cumulativeSuccessRefundCents)} + 应追回 {order.targetRecoveryPoints} / 已追回 {order.recoveredPoints} @@ -1313,14 +1342,10 @@ function formatMembershipChangeSummary( return `${action} ${planPath}${cycle}`; } -function formatMoney(cents: number) { - return `¥${(cents / 100).toFixed(2)}`; -} - /** 实付只属于真正支付过的订单:未支付 / 已关闭 / 已过期订单显示“未支付”。 */ function formatOrderPaidAmount(order: AdminRechargeOrderEntryPayload) { return order.paidAmountCents > 0 - ? formatMoney(order.paidAmountCents) + ? formatAdminCurrencyCents(order.paidAmountCents) : '未支付'; } diff --git a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx index 4b6c3c0ea..a53ab5285 100644 --- a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx @@ -8,6 +8,7 @@ import { AdminPage, AdminPageHeading, AdminPanel, + formatAdminCurrencyCents, formatAdminDateTime, } from '@genarrative/shared/components'; import { RefreshCcw, Save } from 'lucide-react'; @@ -333,7 +334,7 @@ export function AdminRechargeProductPage({ {entry.productId} - {formatPrice(entry.priceCents)} + {formatAdminCurrencyCents(entry.priceCents)} {formatProductContent(entry)} {entry.enabled ? '启用' : '停用'} @@ -384,10 +385,6 @@ function formatProductContent( return `${entry.pointsAmount} 泥点`; } -function formatPrice(priceCents: number) { - return `¥${(priceCents / 100).toFixed(2)}`; -} - function parseInteger(value: string) { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed)) { diff --git a/packages/shared/src/components/admin/AdminFormat.test.ts b/packages/shared/src/components/admin/AdminFormat.test.ts index 13b169cab..f3a8c7466 100644 --- a/packages/shared/src/components/admin/AdminFormat.test.ts +++ b/packages/shared/src/components/admin/AdminFormat.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'vitest'; -import { formatAdminDate, formatAdminDateTime } from './AdminFormat'; +import { + formatAdminCurrencyCents, + formatAdminDate, + formatAdminDateTime, +} from './AdminFormat'; describe('formatAdminDateTime', () => { test('把后端的 seconds.microsZ 口径当成同一时刻渲染', () => { @@ -44,3 +48,11 @@ describe('formatAdminDate', () => { expect(formatAdminDate('1791008678.817257Z')).not.toBe('-'); }); }); + +describe('formatAdminCurrencyCents', () => { + test('分转成带两位小数的元', () => { + expect(formatAdminCurrencyCents(0)).toBe('¥0.00'); + expect(formatAdminCurrencyCents(600)).toBe('¥6.00'); + expect(formatAdminCurrencyCents(1234)).toBe('¥12.34'); + }); +}); diff --git a/packages/shared/src/components/admin/AdminFormat.ts b/packages/shared/src/components/admin/AdminFormat.ts index 21e10f666..d69a0edfc 100644 --- a/packages/shared/src/components/admin/AdminFormat.ts +++ b/packages/shared/src/components/admin/AdminFormat.ts @@ -54,6 +54,11 @@ export function formatAdminDateTime( } } +/** 后台统一的金额展示:分 → `¥12.34`;避免各后台页面各自实现导致格式漂移。 */ +export function formatAdminCurrencyCents(cents: number): string { + return `¥${(cents / 100).toFixed(2)}`; +} + /** 后台统一的日期展示(不含时分秒):同样接受 `seconds.microsZ` 口径。 */ export function formatAdminDate( value: string | number | Date | null | undefined, diff --git a/packages/shared/src/components/admin/index.ts b/packages/shared/src/components/admin/index.ts index 8483c98ba..081fcb5ec 100644 --- a/packages/shared/src/components/admin/index.ts +++ b/packages/shared/src/components/admin/index.ts @@ -19,7 +19,11 @@ export type { AdminFormRowProps, } from './AdminField'; export { AdminField, AdminFieldGroup, AdminFormRow } from './AdminField'; -export { formatAdminDate, formatAdminDateTime } from './AdminFormat'; +export { + formatAdminCurrencyCents, + formatAdminDate, + formatAdminDateTime, +} from './AdminFormat'; export type { AdminInfoItemProps, AdminInfoListEntry, From e90929057c644f595fa4d528ed302c09c8724c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 18:15:36 +0800 Subject: [PATCH 222/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E8=A1=A5=E5=B7=AE):=20ceil=5Fdiv=20=E9=9B=B6=E5=88=86=E6=AF=8D?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E9=9D=99=E9=BB=98=E8=BF=94=E5=9B=9E=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 denominator == 0 时的静默 0 元兜底,保留 debug_assert 并在 release 下除零 panic - 与函数注释「零分母属程序员错误,应立刻暴露」保持一致,避免潜在少收金额 --- server-rs/crates/module-runtime/src/membership/upgrade.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index 64a9ae25a..3a6786325 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -237,12 +237,10 @@ pub fn check_expired_membership_upgrade_settlement( /// 向上取整的整数除法(金额取整到分)。 /// /// 分母是 `MONTHS_PER_YEAR * REMAINING_RATIO_PPM` 这类编译期常量,恒不为 0; -/// 零分母属程序员错误,`debug_assert` 让它在测试里立刻暴露,而不是被静默算成 0 元。 +/// 零分母属程序员错误,`debug_assert` 让它在测试里立刻暴露,release 下除零 panic, +/// 而不是被静默算成 0 元。 fn ceil_div(numerator: u128, denominator: u128) -> u64 { debug_assert!(denominator != 0, "ceil_div 的分母不能为 0"); - if denominator == 0 { - return 0; - } ((numerator + denominator - 1) / denominator) as u64 } From 9a0070044733ce8dca52094f7d75704c7a7c18e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 18:51:53 +0800 Subject: [PATCH 223/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(AGC=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B):=20=E6=B7=BB=E5=8A=A0=E5=8D=8F=E8=AE=AE=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=B9=B6=E7=A1=AE=E4=BF=9D=E6=97=A0=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=97=B6=20`unavailableModels`=20=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E7=A9=BA=E6=95=B0=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/conversationModelSelect.test.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx index 6a4f2a067..e34229b6a 100644 --- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx +++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx @@ -202,7 +202,15 @@ test('shows manual refresh progress immediately without clearing the selected mo await act(async () => { resolveRefresh({ defaultModelId: 'quality', - models: [{ id: 'quality', displayName: '高质量', agentMode: 'codex' }], + models: [ + { + id: 'quality', + displayName: '高质量', + agentMode: 'codex', + protocol: 'openai_responses', + }, + ], + unavailableModels: [], revision: 1, }); }); From 8788c47a9771ed9a0d5edffdefcbf74af2f38c8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 18:59:57 +0800 Subject: [PATCH 224/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(AGC=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B):=20=E9=94=81=E5=AE=9A=E6=A8=A1=E5=9E=8B=E9=A1=B9?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=BF=9B=E5=85=A5=20Tab=20=E5=BA=8F=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 去掉锁定项的 tabIndex,原因改由 aria-label 与 hover 提示暴露,避免键盘停在不可选项 - 移除锁定项失效的 :focus-visible 样式与旧注释 - 更新单测:断言锁定项不可 Tab 聚焦且 aria-label 带原因 --- .../project-workspace/ConversationModelSelect.test.tsx | 8 ++++++-- .../project-workspace/ConversationModelSelect.tsx | 4 ++-- apps/ai-game-creator-shell/src/styles.css | 8 +++----- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx index ab1accc17..f2b502107 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx @@ -84,7 +84,7 @@ afterEach(() => { }); describe('ConversationModelSelect 锁定模型', () => { - it('不可用模型渲染为不可点、可聚焦的原因项', async () => { + it('不可用模型渲染为不可点、可悬停查看原因且不进入 Tab 序列', async () => { mockConfig('basic-model', true); render(); // 等挂载时的目录同步落地,避免把异步状态更新留到断言之后。 @@ -96,8 +96,12 @@ describe('ConversationModelSelect 锁定模型', () => { const planLocked = screen.getByText('专业模型').closest('[role="option"]'); expect(planLocked?.tagName).toBe('DIV'); expect(planLocked?.getAttribute('aria-disabled')).toBe('true'); - expect(planLocked?.getAttribute('tabindex')).toBe('0'); + // 非焦点项:不进入 Tab 序列,避免键盘在不可选项上停留。 + expect(planLocked?.getAttribute('tabindex')).toBeNull(); expect(planLocked?.getAttribute('data-reason')).toBe('plan_required'); + expect(planLocked?.getAttribute('aria-label')).toBe( + '专业模型:订阅计划不支持', + ); expect(planLocked?.getAttribute('data-tooltip')).toBe('订阅计划不支持'); const disabledLocked = screen diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index 951f2274e..040474bf1 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -475,7 +475,8 @@ export function ConversationModelSelect({ ) : null} ))} - {/* 目录里存在但当前档位不可用的模型:不可点,仅 hover / 键盘 focus 出原因。 */} + {/* 目录里存在但当前档位不可用的模型:不可点;原因通过 aria-label 与 hover 提示暴露, + 且刻意不进入 Tab 序列,避免键盘用户在不可选项上停留。 */} {unavailableModels.map((model) => { const reasonLabel = unavailableModelReasonLabel(model.reason); return ( @@ -485,7 +486,6 @@ export function ConversationModelSelect({ aria-selected={false} aria-disabled="true" aria-label={`${model.displayName}:${reasonLabel}`} - tabIndex={0} className="conversation-model-menu-option conversation-model-menu-option-locked" data-reason={model.reason} data-tooltip={reasonLabel} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 792cc2db1..7c200d21f 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -11349,12 +11349,11 @@ button.design-workspace-tree__entry:hover, position: relative; cursor: not-allowed; } -.conversation-model-menu-option-locked:hover, -.conversation-model-menu-option-locked:focus-visible { +.conversation-model-menu-option-locked:hover { background: var(--platform-button-ghost-fill, #f3f4f6); outline: none; } -/* 锁定项的原因提示:仅 hover / 键盘 focus 出现(AGC 桌面端,无移动端适配)。 */ +/* 锁定项的原因提示:仅 hover 出现;锁定项不进入 Tab 序列,不再有键盘 focus 态。 */ .conversation-model-menu-option-locked[data-tooltip]::after { content: attr(data-tooltip); position: absolute; @@ -11373,8 +11372,7 @@ button.design-workspace-tree__entry:hover, white-space: normal; pointer-events: none; } -.conversation-model-menu-option-locked[data-tooltip]:hover::after, -.conversation-model-menu-option-locked[data-tooltip]:focus-visible::after { +.conversation-model-menu-option-locked[data-tooltip]:hover::after { display: block; } .conversation-model-menu-loading { From 6374c7c4456d367110dc3da5ecabeb36fde40b81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 19:23:00 +0800 Subject: [PATCH 225/232] =?UTF-8?q?=E9=87=8D=E6=9E=84(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E5=8D=87=E7=BA=A7):=20=E7=9B=AE=E5=BD=95=E5=80=92=E6=8C=82?= =?UTF-8?q?=E7=94=A8=E7=8B=AC=E7=AB=8B=20rejection=20=E4=B8=8E=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=94=99=E8=AF=AF=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted - 报价遇到价格 / 每期泥点相等或倒挂时返回该变体,不再复用 InvalidQuoteInput - rejection.error_code() 把目录倒挂映射为 InvalidPlanConfig,人读文案保留原因 - profile.rs 三处统一用 membership_upgrade_rejection_error,并重导出该 rejection 类型 - api-server 补 400 + invalid_plan_config 映射断言 --- .../crates/api-server/src/runtime_profile.rs | 5 +++ server-rs/crates/module-runtime/src/domain.rs | 2 +- server-rs/crates/module-runtime/src/lib.rs | 13 ++++--- .../module-runtime/src/membership/upgrade.rs | 37 +++++++++++++++++-- .../src/runtime/active/profile.rs | 34 ++++++++--------- 5 files changed, 62 insertions(+), 29 deletions(-) diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 833f397c0..ee069594f 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -2656,6 +2656,11 @@ mod tests { RuntimeProfileMembershipErrorCode::PlanCatalogMissing, "会员目录缺少档位 pro", ), + // 目录价格 / 每期泥点倒挂属后端配置错误,与 UpgradeRejected 分开暴露机器码。 + ( + RuntimeProfileMembershipErrorCode::InvalidPlanConfig, + "会员升级被拒绝: catalog_pricing_inverted", + ), ] { let error = map_runtime_profile_client_error(SpacetimeClientError::ProcedureRejected { code: code.into(), diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 057336e08..1569ce7d2 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1506,7 +1506,7 @@ pub enum RuntimeProfileMembershipErrorCode { ProductIdUnparseable, /// 会员商品已迁移到档位目录,旧商品不再可用。 MembershipProductRetired, - /// 后台会员档位改价 / 改权益配置校验失败。 + /// 后台会员档位改价 / 改权益配置校验失败,或读取目录时发现价格 / 每期泥点倒挂。 InvalidPlanConfig, } diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 116e51d10..a38eb90b7 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -27,12 +27,13 @@ pub use llm_billing::*; pub use membership::{ MEMBERSHIP_UNLIMITED_CONCURRENCY, PROFILE_MEMBERSHIP_RECOMMENDED_PLAN, RuntimeProfileMembershipPlanRecord, RuntimeProfileMembershipPlanSnapshot, - RuntimeProfileMembershipUpgradeQuoteInput, beijing_local_micros, - build_runtime_profile_membership_plan_snapshot, check_expired_membership_upgrade_settlement, - is_unlimited_concurrency, membership_cycle_window, membership_expires_at, - parse_runtime_profile_membership_product_id, quote_runtime_profile_membership_upgrade, - resolve_runtime_profile_membership_plan_row, runtime_profile_membership_plan_catalog, - runtime_profile_membership_plan_rank, runtime_profile_membership_product_id, + RuntimeProfileMembershipUpgradeQuoteInput, RuntimeProfileMembershipUpgradeRejection, + beijing_local_micros, build_runtime_profile_membership_plan_snapshot, + check_expired_membership_upgrade_settlement, is_unlimited_concurrency, membership_cycle_window, + membership_expires_at, parse_runtime_profile_membership_product_id, + quote_runtime_profile_membership_upgrade, resolve_runtime_profile_membership_plan_row, + runtime_profile_membership_plan_catalog, runtime_profile_membership_plan_rank, + runtime_profile_membership_product_id, validate_runtime_profile_membership_plan_row_against_catalog, }; diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index 3a6786325..e2677ec35 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -77,6 +77,11 @@ pub enum RuntimeProfileMembershipUpgradeRejection { TargetPlanNotPurchasable, /// 报价入参的数值不变量被破坏(期号越界、账期窗口非法或 `now` 不在窗口内):拒绝,而不是静默夹取。 InvalidQuoteInput, + /// 目录价格 / 每期泥点未随 rank 严格递增(相等或倒挂)。 + /// + /// 与 [`Self::InvalidQuoteInput`] 分开:这是后端目录配置错误,不是调用方入参错误, + /// 对外应映射为配置类错误码(`invalid_plan_config`),便于监控区分。 + CatalogPricingInverted, /// 补差价订单结算时会员已到期,且订单金额不够覆盖整期新购价:应重新下单购买。 MembershipExpiredBeforeSettlement, } @@ -89,9 +94,21 @@ impl RuntimeProfileMembershipUpgradeRejection { Self::NotUpgrade => "not_upgrade", Self::TargetPlanNotPurchasable => "target_plan_not_purchasable", Self::InvalidQuoteInput => "invalid_quote_input", + Self::CatalogPricingInverted => "catalog_pricing_inverted", Self::MembershipExpiredBeforeSettlement => "membership_expired_before_settlement", } } + + /// 对外 procedure 错误码:目录价格 / 每期泥点倒挂属后端配置错误,与调用方入参错误分开, + /// 便于按机器码定位;其余拒绝仍统一为 `UpgradeRejected`。 + pub fn error_code(&self) -> crate::RuntimeProfileMembershipErrorCode { + match self { + Self::CatalogPricingInverted => { + crate::RuntimeProfileMembershipErrorCode::InvalidPlanConfig + } + _ => crate::RuntimeProfileMembershipErrorCode::UpgradeRejected, + } + } } /// 本期剩余比例(ppm):`剩余微秒 / 本期实际微秒`,向下取整,已过期或时长非法时为 0。 @@ -184,8 +201,9 @@ pub fn quote_runtime_profile_membership_upgrade( // 目录价格 / 每期泥点必须严格随 rank 递增;相等或倒挂都直接拒绝, // 否则 checked_sub 会把「相等」算成 0 元升级 / 0 补点,违背目录不变量。 + // 这是后端目录配置错误,用独立变体与调用方入参错误区分。 if target.price_cents <= current.price_cents || target.period_points <= current.period_points { - return Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput); + return Err(RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted); } let price_delta_cents = target.price_cents - current.price_cents; let points_delta = target.period_points - current.period_points; @@ -247,6 +265,7 @@ fn ceil_div(numerator: u128, denominator: u128) -> u64 { #[cfg(test)] mod tests { use super::*; + use crate::RuntimeProfileMembershipErrorCode; use crate::RuntimeProfileMembershipPlan; use crate::membership::catalog::resolve_runtime_profile_membership_plan_snapshot; use crate::membership::cycle::beijing_local_micros; @@ -485,7 +504,7 @@ mod tests { target: target.clone(), ..base_input() }), - Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + Err(RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted) ); target.price_cents = current.price_cents + 1; @@ -496,7 +515,7 @@ mod tests { target: target.clone(), ..base_input() }), - Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + Err(RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted) ); // 中文注释:相等同样破坏「严格随 rank 递增」,必须拒绝;旧实现用 checked_sub @@ -509,7 +528,17 @@ mod tests { target, ..base_input() }), - Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + Err(RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted) + ); + + // 中文注释:目录倒挂对外映射为配置错误码,与调用方入参错误(UpgradeRejected)区分。 + assert_eq!( + RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted.error_code(), + RuntimeProfileMembershipErrorCode::InvalidPlanConfig + ); + assert_eq!( + RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput.error_code(), + RuntimeProfileMembershipErrorCode::UpgradeRejected ); } 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 18ad8f4ed..0bffc6c79 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -8764,12 +8764,7 @@ fn build_profile_membership_upgrade_quote_record( &target, ctx.timestamp, )) - .map_err(|rejection| { - RuntimeProfileMembershipDomainError::new( - RuntimeProfileMembershipErrorCode::UpgradeRejected, - format!("会员升级被拒绝: {}", rejection.as_str()), - ) - })?; + .map_err(membership_upgrade_rejection_error)?; Ok(RuntimeProfileMembershipUpgradeQuoteSnapshot { current_plan: current.plan, @@ -8786,6 +8781,19 @@ fn build_profile_membership_upgrade_quote_record( }) } +/// 会员升级拒绝 → procedure 错误码。 +/// +/// 目录价格 / 每期泥点倒挂是后端配置错误,映射为 `InvalidPlanConfig`,与客户端入参错误 +/// (`UpgradeRejected`)区分开,便于按错误码定位;人读文案仍带具体 rejection 原因。 +fn membership_upgrade_rejection_error( + rejection: RuntimeProfileMembershipUpgradeRejection, +) -> RuntimeProfileMembershipDomainError { + RuntimeProfileMembershipDomainError::new( + rejection.error_code(), + format!("会员升级被拒绝: {}", rejection.as_str()), + ) +} + /// 后台改价 / 改权益。`rank` 与 `plan` 不可改:`plan` 是主键(即身份),`rank` 是升级比较基准。 fn upsert_profile_membership_plan_record( ctx: &ReducerContext, @@ -10107,12 +10115,7 @@ fn build_profile_membership_order_snapshot( let quote = quote_runtime_profile_membership_upgrade(membership_upgrade_quote_input( &row, ¤t, &target, created_at, )) - .map_err(|rejection| { - RuntimeProfileMembershipDomainError::new( - RuntimeProfileMembershipErrorCode::UpgradeRejected, - format!("会员升级被拒绝: {}", rejection.as_str()), - ) - })?; + .map_err(membership_upgrade_rejection_error)?; Ok(ProfileMembershipOrderSnapshot { change_kind: RuntimeProfileMembershipChangeKind::Upgrade, plan, @@ -10731,12 +10734,7 @@ fn build_recomputed_profile_membership_settlement( &target, purchased_at, )) - .map_err(|rejection| { - RuntimeProfileMembershipDomainError::new( - RuntimeProfileMembershipErrorCode::UpgradeRejected, - format!("会员升级被拒绝: {}", rejection.as_str()), - ) - })?; + .map_err(membership_upgrade_rejection_error)?; Ok(ProfileMembershipSettlement { change_kind: RuntimeProfileMembershipChangeKind::Upgrade, plan, From 00bb2a7b15c923e7d6044d6c1f86d16f0abc21ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 19:24:57 +0800 Subject: [PATCH 226/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1):=20=E5=90=8E=E5=8F=B0=E6=94=B9=E6=A1=A3?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E6=94=B9=E7=94=A8=20SpacetimeDB=20=E4=BA=8B?= =?UTF-8?q?=E5=8A=A1=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - upsert_profile_membership_plan_record 不再信任入参 updated_at_micros - updated_at / created_at 统一取 ctx.timestamp,客户端时钟无法伪造审计时间 --- .../crates/spacetime-module/src/runtime/active/profile.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 0bffc6c79..68b4f0586 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -8814,7 +8814,9 @@ fn upsert_profile_membership_plan_record( .map_err(|error| error.to_string())?; ensure_default_profile_membership_plan(ctx); - let updated_at = Timestamp::from_micros_since_unix_epoch(validated.updated_at_micros); + // 审计时间以 SpacetimeDB 事务时间为准:入参 `updated_at_micros` 不再被信任, + // 因为 procedure 可被持有 writer 身份者直接调用,客户端时钟可以伪造。 + let updated_at = ctx.timestamp; let existing = ctx .db .profile_membership_plan() From a938a4ef9eb566028afaddbdb746997a63e738c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 19:37:48 +0800 Subject: [PATCH 227/232] =?UTF-8?q?=E5=85=BC=E5=AE=B9(=E5=A5=91=E7=BA=A6):?= =?UTF-8?q?=20=E5=93=8D=E5=BA=94=20DTO=20=E6=96=B0=E5=A2=9E=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E4=B8=80=E5=BE=8B=E5=8F=AF=E9=80=89=E9=BB=98=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProfileMudPointBalanceResponse.monthly_points 缺省为 0 - ProfileMembershipResponse 的 cycleIndex / cycleCount / cycleKind 缺省 1 / 1 / yearly - ProfileMembershipPlanResponse 的 recommended / unlimitedConcurrency 缺省 false - ProfileRechargeCenterResponse.membershipPlans 缺省空数组 - 补「旧服务端缺字段仍可整包解析」契约测试,身份与状态字段仍保持必填 - 后端架构文档新增 shared-contracts DTO 变更规则,并同步团队约定 --- .../shared-memory/team-conventions.md | 1 + ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 8 ++ .../crates/shared-contracts/src/runtime.rs | 118 +++++++++++++++++- 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index 8fb88c73c..a584c0285 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -57,6 +57,7 @@ - 对已明确退役且无现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,直接清理实现、专属测试和说明,将权威文档更新为当前状态;历史由 Git 保存。公开契约、持久化数据和正式迁移按实际需求保留最小兼容及对应测试。 - 修改 `/api/external/v1` 时,同批更新 `docs/openapi/genarrative-external-v1.openapi.json` 与契约测试。 - 修改 SpacetimeDB schema 时遵守字段追加/default 约束,同步 migration、表目录、生成绑定,并运行 schema 检查;删除、改名、重排或改类型前先确认迁移计划。 +- `shared-contracts` 响应 DTO 只增不删:新增字段一律可选(`Option`,或带安全 `#[serde(default ...)]`),身份 / 状态字段保持必填;删除、改名、改类型或改必填属性前先确认版本化计划,不保留仅服务旧客户端的 legacy shim。详见后端架构文档「shared-contracts DTO 变更规则」。 - 日志不递归输出完整配置、应用状态或 provider client;新增字段默认不进入安全摘要。所有 provider 配置类型(`LlmConfig`、`OssConfig`、`WechatConfig`、`WechatPayConfig`、`MattingConfig`、`VolcengineSpeechConfig`、`Hyper3dSettings`、`VectorEngineImageSettings`、`VectorEngineAudioSettings`)都必须手写脱敏 `Debug`:只输出枚举、数值、布尔、有界标识与 `` 占位,凭据、私钥、路径与 URL 一律不递归格式化,并各带一条哨兵值回归用例(`cargo test -p platform-* debug`)。新增同类配置必须照此办理,不允许直接 `derive(Debug)`。 - HTTP 横切能力集中在 Axum/Tower 中间件:正常与降级路由复用追踪层;指标与 trace 使用 `MatchedPath` 模板及固定兜底,不把请求 ID、实际资源 ID 或 query 放入指标标签。在途请求通过 RAII guard 覆盖 Future 取消与 panic unwind;请求执行和响应体存活分别计量,不能把 handler 耗时当作 SSE 全生命周期。 - 业务依赖在组合根显式装配,Axum `FromRef` 只抽取可浅拷贝的窄能力。项目元数据与 External API 鉴权不持有完整 `AppState`,测试经相同接口注入替代依赖。集中鉴权仍保留方法级 fallback、公开入口、MCP 和 body limit 顺序;Provider span 跳过完整参数,不隐藏计费、重试、幂等或事务规则。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index d126fdf18..c4098f335 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -151,6 +151,14 @@ npm run check:server-rs-ddd 本地联调和人工排障不要使用 `spacetime --root-dir`。本地数据隔离使用项目脚本或 `--data-dir`;发布目标显式传 `--server` / `--server-url`。 +## shared-contracts DTO 变更规则 + +1. 面向前端 / AGC 壳的**响应** DTO 只增不删:新增字段一律可选——`Option`,或非 `Option` 时带 `#[serde(default ...)]` 且默认值安全(空集合 / `0` / `false` / 最小合法值)。这样「客户端先于服务端发布」的滚动升级不会因为旧服务端缺字段而整包解析失败;这是兼容未来版本的手段,不是给当前旧客户端保留 legacy shim。 +2. 身份与状态类字段(例如 `ProfileMembershipResponse.plan` / `status`、`enabled`)保持必填,不给默认值:fail-open 会把付费会员误显示成非会员或反过来,静默错误比整包失败更难排查。 +3. 未知 token 值继续走 `deserialize_forward_compat_*` 兜底;它与「缺字段默认值」是两条独立路径,都要有契约测试(旧服务端缺字段、新服务端多发未知取值各一条)。 +4. 删除字段、改名、改类型或改必填属性属于破坏性变更,必须先确认版本化 / 迁移计划并更新权威文档;除非明确要求新旧客户端混跑,否则不为旧客户端保留 legacy shim。 +5. 响应 DTO 的 serde 属性不影响 ts-rs 绑定;字段集合或类型变化必须重新生成绑定并运行 `npm run check:generated-bindings`。 + ## 账户充值数据契约 1. `profile_recharge_product_config` 是泥点和会员商品配置真相源,默认商品只在表为空时由 SpacetimeDB 播种。`module-runtime` 中的默认商品 helper 只作为空库种子和兼容入口,不再作为运行期业务真相。 diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 8e57dd995..3b869ca74 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -234,6 +234,7 @@ pub struct ProfileMudPointBalanceResponse { pub daily_free_reset_points: u64, pub daily_free_resets_at: String, /// 月度泥点:会员每期额度,到刷新时刻清零再发新额度。 + #[serde(default)] #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub monthly_points: u64, pub monthly_resets_at: Option, @@ -419,6 +420,10 @@ impl ProfileMembershipChangeKindToken { /// 未知取值统一落到「已知的最高能力」档(plan→`max`、status→`active`、cycle→`yearly`、 /// model_access→`full`):展示上按 fail-open 处理,真实权益与下单金额一律以后端重算为准, /// 避免把付费会员误显示成非会员后引导重复购买。 +/// +/// 字段层面同样做前向兼容:本模块新增的响应字段一律带 `#[serde(default ...)]`,让「客户端先于 +/// 服务端发布」的滚动升级不会因为旧服务端缺字段而整包失败。身份 / 状态字段(`plan` / `status` +/// / `enabled`)保持必填:给它们兜底默认值会造成 fail-open 或 fail-closed 的静默误判。 fn deserialize_forward_compat_membership_plan<'de, D>( deserializer: D, ) -> Result @@ -460,11 +465,26 @@ where Ok( match Option::::deserialize(deserializer)?.as_deref() { Some("monthly") => ProfileMembershipCycleKindToken::Monthly, - _ => ProfileMembershipCycleKindToken::Yearly, + _ => default_forward_compat_membership_cycle_kind(), }, ) } +/// `cycle_kind` 缺失时的兜底(旧服务端还没下发该字段):与未知 token 一样落到 `yearly`。 +fn default_forward_compat_membership_cycle_kind() -> ProfileMembershipCycleKindToken { + ProfileMembershipCycleKindToken::Yearly +} + +/// `cycle_index` 缺失时的兜底:1-based,最小合法期号是 1。 +fn default_membership_cycle_index() -> u32 { + 1 +} + +/// `cycle_count` 缺失时的兜底:月付 1 期,是与 1-based 期号自洽的最小值。 +fn default_membership_cycle_count() -> u32 { + 1 +} + fn deserialize_forward_compat_model_access<'de, D>( deserializer: D, ) -> Result @@ -538,8 +558,10 @@ pub struct ProfileMembershipPlanResponse { /// 并发上限;达到后端下发的 `unlimitedConcurrencyThreshold` 即表示不设上限,前端不硬编码哨兵值。 pub concurrent_job_limit: u32, /// 是否为运营推荐档位;由后端目录决定,前端不再硬编码档位 token。 + #[serde(default)] pub recommended: bool, /// 是否不设并发上限;由后端按哨兵值派生,前端不再硬编码 `128`。 + #[serde(default)] pub unlimited_concurrency: bool, pub enabled: bool, } @@ -574,11 +596,16 @@ pub struct ProfileMembershipResponse { #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub cycle_remaining_points: u64, /// 当前期序号(1-based)。 + #[serde(default = "default_membership_cycle_index")] pub cycle_index: u32, /// 本次购买总期数(月付 1 / 年付 12)。 + #[serde(default = "default_membership_cycle_count")] pub cycle_count: u32, /// 周期 token:`monthly` / `yearly`;未知取值按 `yearly` 兜底。 - #[serde(deserialize_with = "deserialize_forward_compat_membership_cycle_kind")] + #[serde( + default = "default_forward_compat_membership_cycle_kind", + deserialize_with = "deserialize_forward_compat_membership_cycle_kind" + )] pub cycle_kind: ProfileMembershipCycleKindToken, } @@ -675,6 +702,7 @@ pub struct ProfileRechargeCenterResponse { pub membership: ProfileMembershipResponse, pub point_products: Vec, /// 会员档位目录(后台可改价),会员购买 / 升级的价格与权益都以它为准。 + #[serde(default)] pub membership_plans: Vec, pub latest_order: Option, pub has_points_recharged: bool, @@ -2431,6 +2459,92 @@ mod tests { assert_eq!(known, ProfileMembershipStatusToken::Normal); } + #[test] + fn membership_response_tolerates_fields_missing_from_older_backend() { + // 客户端先于服务端发布(滚动升级)时,旧服务端缺少本轮新增字段不能整包失败; + // 新增字段一律带默认值,这里锁住默认值的具体取值。 + let membership: ProfileMembershipResponse = serde_json::from_value(json!({ + "status": "active", + "plan": "plus", + "startedAt": null, + "expiresAt": null, + "updatedAt": null, + "cycleStartedAt": null, + "cycleResetsAt": null, + "cycleGrantedPoints": 0, + "cycleRemainingPoints": 0 + })) + .expect("older membership payload must still deserialize"); + assert_eq!(membership.cycle_index, 1); + assert_eq!(membership.cycle_count, 1); + assert_eq!( + membership.cycle_kind, + ProfileMembershipCycleKindToken::Yearly + ); + + let balance: ProfileMudPointBalanceResponse = serde_json::from_value(json!({ + "dailyFreePoints": 0, + "dailyFreeResetPoints": 20, + "dailyFreeResetsAt": "2026-07-12T16:00:00Z", + "monthlyResetsAt": null, + "permanentPoints": 0 + })) + .expect("older balance payload must still deserialize"); + assert_eq!(balance.monthly_points, 0); + + let plan: ProfileMembershipPlanResponse = serde_json::from_value(json!({ + "plan": "plus", + "title": "Plus", + "rank": 3, + "monthPriceCents": 100, + "yearPriceCents": 1000, + "periodPoints": 100, + "modelAccess": "full", + "concurrentJobLimit": 8, + "enabled": true + })) + .expect("older plan payload must still deserialize"); + assert!(!plan.recommended); + assert!(!plan.unlimited_concurrency); + + let center: ProfileRechargeCenterResponse = serde_json::from_value(json!({ + "mudPointBalance": { + "dailyFreePoints": 0, + "dailyFreeResetPoints": 20, + "dailyFreeResetsAt": "2026-07-12T16:00:00Z", + "monthlyPoints": 0, + "monthlyResetsAt": null, + "permanentPoints": 0 + }, + "membership": { + "status": "normal", + "plan": "normal", + "startedAt": null, + "expiresAt": null, + "updatedAt": null, + "cycleStartedAt": null, + "cycleResetsAt": null, + "cycleGrantedPoints": 0, + "cycleRemainingPoints": 0, + "cycleIndex": 1, + "cycleCount": 1, + "cycleKind": "monthly" + }, + "pointProducts": [], + "latestOrder": null, + "hasPointsRecharged": false, + "dailyFreePoints": { + "dayKey": 0, + "grantedPoints": 20, + "remainingPoints": 20, + "resetsAt": "2026-07-12T16:00:00Z", + "updatedAt": "2026-07-12T00:00:00Z" + } + })) + .expect("older recharge center payload must still deserialize"); + assert!(center.membership_plans.is_empty()); + } + #[test] fn membership_request_tokens_stay_strict_for_unknown_values() { // 入参 DTO 不做兜底:非法目标档位必须在反序列化即被拒绝。 From e27ce5573fb4c84473102783852dba1e7df253ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 20:22:37 +0800 Subject: [PATCH 228/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E6=A1=A3=E4=BD=8D):=20=E7=BC=BA=E8=A1=8C=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E9=80=90=E6=A1=A3=E5=9B=9E=E5=A1=AB=E5=B9=B6?= =?UTF-8?q?=E5=9C=A8=E5=86=99=E5=85=A5=E5=89=8D=E6=A0=A1=E9=AA=8C=E8=A6=86?= =?UTF-8?q?=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ensure_default_profile_membership_plan 从「空表整表播种」改为按目录逐档补齐缺失行,已存在行不覆盖后台改价 - 新增 missing_runtime_profile_membership_plan_rows 纯函数并在 module-runtime 根导出,回填逻辑可单测 - validate_runtime_profile_membership_plan_row_against_catalog 增加「existing 必须覆盖除 candidate 外全部档位」校验,缺行直接拒绝写入 - 更新 init 注释:懒补齐而非懒播种,全部档位补齐后幂等无写 - 补缺行拒绝与回填幂等两条目录单测 --- server-rs/crates/module-runtime/src/lib.rs | 8 +-- .../module-runtime/src/membership/catalog.rs | 66 +++++++++++++++++-- .../src/runtime/active/profile.rs | 23 ++++--- 3 files changed, 79 insertions(+), 18 deletions(-) diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index a38eb90b7..cedd299e1 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -30,10 +30,10 @@ pub use membership::{ RuntimeProfileMembershipUpgradeQuoteInput, RuntimeProfileMembershipUpgradeRejection, beijing_local_micros, build_runtime_profile_membership_plan_snapshot, check_expired_membership_upgrade_settlement, is_unlimited_concurrency, membership_cycle_window, - membership_expires_at, parse_runtime_profile_membership_product_id, - quote_runtime_profile_membership_upgrade, resolve_runtime_profile_membership_plan_row, - runtime_profile_membership_plan_catalog, runtime_profile_membership_plan_rank, - runtime_profile_membership_product_id, + membership_expires_at, missing_runtime_profile_membership_plan_rows, + parse_runtime_profile_membership_product_id, quote_runtime_profile_membership_upgrade, + resolve_runtime_profile_membership_plan_row, runtime_profile_membership_plan_catalog, + runtime_profile_membership_plan_rank, runtime_profile_membership_product_id, validate_runtime_profile_membership_plan_row_against_catalog, }; diff --git a/server-rs/crates/module-runtime/src/membership/catalog.rs b/server-rs/crates/module-runtime/src/membership/catalog.rs index 8d0bce005..828f14b55 100644 --- a/server-rs/crates/module-runtime/src/membership/catalog.rs +++ b/server-rs/crates/module-runtime/src/membership/catalog.rs @@ -140,16 +140,28 @@ pub fn resolve_runtime_profile_membership_plan_row( .find(|row| row.plan == plan) } +/// 目录里缺失的档位行(`existing` 中不存在的目录行),按目录顺序返回。 +/// +/// 用于把「只在空表时整表播种」改成「缺哪行补哪行」的幂等回填:老库可能只有部分档位, +/// 只在空表播种会让缺行的库永远缺下去。已存在的行不返回,调用方只插入缺失行、不覆盖后台改价。 +pub fn missing_runtime_profile_membership_plan_rows( + existing: &[RuntimeProfileMembershipPlanRecord], +) -> Vec { + runtime_profile_membership_plan_catalog() + .into_iter() + .filter(|required| !existing.iter().any(|row| row.plan == required.plan)) + .collect() +} + /// 校验后台写入的档位不会破坏目录不变量。 /// /// `candidate` 是本次要落库的行,`existing` 是目录里的其余行(同 `plan` 的旧行会被忽略)。 -/// 只比较 `candidate` 与其余每一行:这样任何单次改写都不能制造新的倒挂,而目录原本单调时 -/// 写完后仍严格单调;同时历史脏目录不会把后台彻底锁死,后台仍能逐档修正。 +/// 先校验 `existing` 覆盖除 `candidate.plan` 外的全部目录档位,再逐行比较 `candidate` 与其余 +/// 每一行:这样任何单次改写都不能制造新的倒挂,也不会因为「漏传某一行」而静默放过倒挂。 /// -/// **前置条件**:`existing` 必须覆盖除 `candidate.plan` 外的全部在售档位。本函数只校验 -/// `candidate` 相对现存行的单调性,无法发现「缺失档位」导致的倒挂(例如漏传某一行时, -/// 月价 / 年价 / 每期泥点的反转关系会静默通过,之后才在报价或目录读取时暴露)。当前唯一 -/// 调用方传入完整目录表,新增调用方时必须保持这一约定。 +/// **调用方前置动作**:缺行的历史库必须先经 `ensure_default_profile_membership_plan` 回填 +/// 缺失档位(该函数按档位逐行补齐,不再只在空表时整表播种),否则本函数会直接拒绝写入。 +/// 这是有意的:缺行库继续允许任意单行改写会让目录长期残缺,报价时才暴露错误。 /// /// `model_access` 与 `concurrent_job_limit` 本期只落字段、只用于展示与人工判断,服务端不做拦截, /// 因此这里不校验它们随 rank 单调;若将来据其做准入判定,需要在此补充倒挂校验。 @@ -157,6 +169,16 @@ pub fn validate_runtime_profile_membership_plan_row_against_catalog( candidate: &RuntimeProfileMembershipPlanRecord, existing: &[RuntimeProfileMembershipPlanRecord], ) -> Result<(), RuntimeProfileFieldError> { + // 覆盖校验:缺一行就无法判断它与 candidate 的相对关系,必须显式拒绝而不是静默跳过。 + for required in runtime_profile_membership_plan_catalog() { + if required.plan == candidate.plan { + continue; + } + if !existing.iter().any(|row| row.plan == required.plan) { + return Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder); + } + } + for row in existing.iter().filter(|row| row.plan != candidate.plan) { if row.rank == candidate.rank { return Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder); @@ -379,6 +401,38 @@ mod tests { } } + #[test] + fn admin_upsert_rejects_a_catalog_missing_a_plan() { + // 中文注释:缺行的库(例如老版本档位更少)必须先回填再校验。漏传其它档位时, + // 即使 candidate 本身与现存行单调,也必须拒绝,避免残缺目录继续扩大。 + let candidate = row(RuntimeProfileMembershipPlan::Plus); + let existing = vec![candidate.clone()]; + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &existing), + Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder) + ); + } + + #[test] + fn missing_plan_rows_backfills_only_absent_plans() { + // 老库只有 Plus:应补出目录里其余全部档位,且不重复返回已存在的 Plus。 + let existing = vec![row(RuntimeProfileMembershipPlan::Plus)]; + let missing = missing_runtime_profile_membership_plan_rows(&existing); + let plans = missing.iter().map(|row| row.plan).collect::>(); + let expected = runtime_profile_membership_plan_catalog() + .into_iter() + .map(|row| row.plan) + .filter(|plan| *plan != RuntimeProfileMembershipPlan::Plus) + .collect::>(); + assert_eq!(plans, expected); + + // 目录已补齐时不返回任何行:读取入口幂等、不再写库。 + assert!( + missing_runtime_profile_membership_plan_rows(&runtime_profile_membership_plan_catalog()) + .is_empty() + ); + } + #[test] fn admin_upsert_allows_a_monotonic_price_change() { let catalog = runtime_profile_membership_plan_catalog(); 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 68b4f0586..6b5cc12fe 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -9373,12 +9373,19 @@ fn ensure_default_profile_task_config(ctx: &ReducerContext) -> ProfileTaskConfig /// 仍要靠读取 helper 懒播种。**代价**:报价 / 充值中心 / 后台查询等只读入口在目录为空的首次调用会写库; /// 表非空后本函数直接返回,后续调用保持只读。若将来改为发布期统一播种,再删除这里的懒播种与注释。 fn ensure_default_profile_membership_plan(ctx: &ReducerContext) { - if ctx.db.profile_membership_plan().count() > 0 { - return; - } + // 中文注释:按档位逐行补齐,而不是只在空表时整表播种——增量发布不会重跑 `init`, + // 老库可能只有部分档位;只在空表播种会让缺行的库永远缺下去,目录覆盖校验与报价都会受影响。 + // 已存在的行保持后台改过的价格 / 权益不动,只补目录里没有的档位。 + let existing = ctx + .db + .profile_membership_plan() + .iter() + .map(|row| membership_plan_record_from_row(&row)) + .collect::>(); + let missing = module_runtime::missing_runtime_profile_membership_plan_rows(&existing); let now = ctx.timestamp; - for row in runtime_profile_membership_plan_catalog() { + for row in missing { ctx.db .profile_membership_plan() .insert(ProfileMembershipPlan { @@ -9399,12 +9406,12 @@ fn ensure_default_profile_membership_plan(ctx: &ReducerContext) { } } -/// 模块初始化生命周期:只在数据库首次创建时把会员档位目录整表播种。 +/// 模块初始化生命周期:数据库首次创建时把会员档位目录整表播种。 /// /// 本 reducer **不覆盖**「目录表已存在但为空」的存放量库:增量发布不会重跑 `init`。这类库由 -/// 读取 helper 里的 [`ensure_default_profile_membership_plan`] 懒播种兜底,因此报价 / 充值中心 / -/// 后台查询等「只读」入口首次调用仍可能写库(表非空后幂等只读)。这是当前有意保留的播种策略, -/// 不是漏改;若改为发布期统一播种,需同步删除懒播种调用并更新本注释。 +/// 读取 helper 里的 [`ensure_default_profile_membership_plan`] 按档位逐行补齐兜底,因此报价 / +/// 充值中心 / 后台查询等「只读」入口首次调用仍可能写库(全部档位补齐后幂等无写)。这是当前 +/// 有意保留的播种策略,不是漏改;若改为发布期统一回填,需同步删除懒补齐调用并更新本注释。 #[spacetimedb::reducer(init)] pub fn init_profile_membership_plan_catalog(ctx: &ReducerContext) { ensure_default_profile_membership_plan(ctx); From d917d2f4d14ca8b4cac741eb46d3704274b55d40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 20:22:43 +0800 Subject: [PATCH 229/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(AGC=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B):=20=E4=BC=9A=E5=91=98=E5=88=B0=E8=B4=A6=E5=90=8E?= =?UTF-8?q?=E4=B8=BB=E5=8A=A8=E5=88=B7=E6=96=B0=E6=A8=A1=E5=9E=8B=E7=9B=AE?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - confirmNativeRechargePayment 确认订单为 paid 且 kind=membership 时调用 notifyLlmConfigChanged - 保留 applyCatalog 的 revision 门控不变,由配置变更事件重置已应用 revision 并重新读取 - 补「revision 不变但账号档位变化时,显式通知必须重新应用」的回归测试 --- .../features/app-shell/useAccountWallet.ts | 6 +++++ .../tests/conversationModelSelect.test.tsx | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts index d95dabd72..63d8d9d82 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts @@ -19,6 +19,7 @@ import { getClientProfileWalletLedger, redeemClientProfileRewardCode, } from '../../services/accountHost'; +import { notifyLlmConfigChanged } from '../../services/llmModelCatalog'; import { useWalletStore } from '../../stores/useWalletStore'; export function useAccountWallet(currentUserId: string) { @@ -363,6 +364,11 @@ export function useAccountWallet(currentUserId: string) { applyRechargeContent(walletSnapshot, response.center); if (response.order.status === 'paid') { setNativeRechargePayment(null); + // 会员到账会改变账号可用的模型档位,但目录 revision 不变;显式通知 + // ConversationModelSelect 重置已应用 revision 并重新读取,否则锁定列表保持陈旧。 + if (response.order.kind === 'membership') { + notifyLlmConfigChanged(); + } void onWalletBalanceMayHaveChanged(); return; } diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx index 6a4f2a067..2fcecb599 100644 --- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx +++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx @@ -747,3 +747,27 @@ test('keeps the applied catalog when the revision is unchanged', async () => { expect(screen.queryByRole('option', { name: '视觉' })).toBeNull(); expect(screen.getByRole('option', { name: '快速' })).not.toBeNull(); }); + +test('applies a changed account catalog after an explicit config-changed notification', async () => { + const onReady = vi.fn(); + render(); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + + // 会员升降级只改变账号可用档位,目录 revision 不变;显式 notify 必须重置已应用 revision + // 并强制重新应用,否则锁定列表会保持陈旧(上一条用例锁住「仅靠 focus 不会更新」)。 + vi.mocked(loadClientLlmModels).mockResolvedValue({ + defaultModelId: 'quality', + models: [ + { id: 'quality', displayName: '高质量', agentMode: 'codex' }, + { id: 'vision', displayName: '视觉', agentMode: 'codex' }, + ], + revision: 1, + }); + act(() => notifyLlmConfigChanged()); + await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2)); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + await waitFor(() => + expect(screen.getByRole('option', { name: '视觉' })).not.toBeNull(), + ); + expect(screen.queryByRole('option', { name: '快速' })).toBeNull(); +}); From 39327f1c8aa3135789814ab9ce9fcd3d65a4b985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 20:22:57 +0800 Subject: [PATCH 230/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E9=97=A8=E7=A6=81):?= =?UTF-8?q?=20=E6=B8=85=E7=90=86=E6=8C=87=E5=90=91=E5=B7=B2=E5=88=A0?= =?UTF-8?q?=E9=99=A4=20profile.rs=20=E7=9A=84=E5=A4=B1=E6=95=88=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E6=9D=A1=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - check-spacetime-runtime-access 的 6 条 runtime/profile.rs 规则改指向现役 runtime/active/profile.rs,恢复索引扫描守卫 - check-server-rs-ddd-boundaries 从退役源集合移除 runtime/profile.rs;active/profile.rs 是现役文件,加入退役集合会漏扫表定义 - npm run check:server-rs-ddd 恢复通过(schema / runtime-access / module-runtime-artifact / DDD 边界) --- scripts/check-server-rs-ddd-boundaries.mjs | 1 - scripts/check-spacetime-runtime-access.mjs | 12 ++++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/scripts/check-server-rs-ddd-boundaries.mjs b/scripts/check-server-rs-ddd-boundaries.mjs index 9a77b9633..aecacf094 100644 --- a/scripts/check-server-rs-ddd-boundaries.mjs +++ b/scripts/check-server-rs-ddd-boundaries.mjs @@ -70,7 +70,6 @@ const retiredOriginalSpacetimeSources = new Set([ 'runtime/admin_work_visibility.rs', 'runtime/browse_history.rs', 'runtime/creation_entry_config.rs', - 'runtime/profile.rs', 'runtime/settings.rs', 'runtime/snapshots.rs', ]); diff --git a/scripts/check-spacetime-runtime-access.mjs b/scripts/check-spacetime-runtime-access.mjs index e48e102b9..84f43c482 100644 --- a/scripts/check-spacetime-runtime-access.mjs +++ b/scripts/check-spacetime-runtime-access.mjs @@ -54,39 +54,39 @@ const forbiddenSnippets = [ 'ai_result_reference 快照组装应使用 by_ai_result_reference_task_id 索引', }, { - file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs', snippet: '.profile_save_archive()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)', reason: 'profile_save_archive 已有 by_profile_save_archive_user_id 索引', }, { - file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs', snippet: '.profile_played_world()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)', reason: 'profile_played_world 已有 by_profile_played_world_user_id 索引', }, { - file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs', snippet: '.profile_wallet_ledger()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)', reason: 'profile_wallet_ledger 已有 by_profile_wallet_ledger_user_id 索引', }, { - file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs', snippet: '.profile_referral_relation()\n .iter()\n .filter(|row| row.inviter_user_id == user_id)', reason: 'profile_referral_relation 已有 by_profile_referral_inviter_user_id 索引', }, { - file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs', snippet: '.profile_recharge_order()\n .iter()\n .filter(|row| row.user_id == user_id)', reason: 'profile_recharge_order 已有 by_profile_recharge_order_user_id 索引', }, { - file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs', snippet: '.tracking_daily_stat()\n .iter()\n .filter(|row| {', reason: 'tracking_daily_stat 已有 by_tracking_daily_stat_scope_day / event_day 索引,analytics 查询不应整表过滤', From a38407344ddcabf40442127d94a8a9abd9d27779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 6 Oct 2026 10:23:16 +0800 Subject: [PATCH 231/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E9=92=B1=E5=8C=85st?= =?UTF-8?q?ore):=20=E5=BA=94=E7=94=A8=E4=BD=99=E9=A2=9D=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E6=97=B6=E7=A9=BA=E5=80=BC=E5=AE=89=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - captureWalletBalanceSnapshot 在 owner 不匹配时返回 null,applyWalletBalanceSnapshot 之前会直接解引用 - applyWalletBalanceSnapshot 入参放宽为 ProfileWalletBalanceSnapshot | null,null 直接返回 false - 单测去掉非空断言,锁住「他人 owner 的快照捕获为 null 且必须被拒绝」 --- .../shared/src/stores/createProfileWalletStore.test.ts | 2 +- packages/shared/src/stores/createProfileWalletStore.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/stores/createProfileWalletStore.test.ts b/packages/shared/src/stores/createProfileWalletStore.test.ts index 88c5da148..daba9d20f 100644 --- a/packages/shared/src/stores/createProfileWalletStore.test.ts +++ b/packages/shared/src/stores/createProfileWalletStore.test.ts @@ -296,7 +296,7 @@ describe('createProfileWalletStore', () => { const snapshot = store.getState().captureWalletBalanceSnapshot('user-b'); expect( - store.getState().applyWalletBalanceSnapshot(snapshot!, balance(37)), + store.getState().applyWalletBalanceSnapshot(snapshot, balance(37)), ).toBe(false); expect(store.getState().mudPointBalance).toBeNull(); }); diff --git a/packages/shared/src/stores/createProfileWalletStore.ts b/packages/shared/src/stores/createProfileWalletStore.ts index 211ba8800..c63c032c6 100644 --- a/packages/shared/src/stores/createProfileWalletStore.ts +++ b/packages/shared/src/stores/createProfileWalletStore.ts @@ -35,7 +35,7 @@ export type ProfileWalletStore = { ownerUserId: string, ) => ProfileWalletBalanceSnapshot | null; applyWalletBalanceSnapshot: ( - snapshot: ProfileWalletBalanceSnapshot, + snapshot: ProfileWalletBalanceSnapshot | null, balance: ProfileMudPointBalance, ) => boolean; onWalletBalanceMayHaveChanged: () => Promise; @@ -117,6 +117,11 @@ export function createProfileWalletStore( }; }, applyWalletBalanceSnapshot: (snapshot, balance) => { + // captureWalletBalanceSnapshot 在 owner 不匹配时返回 null;null 本身即「快照已失效」, + // 直接拒绝,避免把 capture 的可空返回喂回 apply 时解引用崩溃。 + if (!snapshot) { + return false; + } if ( get().ownerUserId !== snapshot.ownerUserId || ownerVersion !== snapshot.ownerVersion || From 3ca01351cb3569065a3774cabadfb7d74fca9866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 6 Oct 2026 10:23:21 +0800 Subject: [PATCH 232/232] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E9=A1=B6=E6=A0=8F):=20=E6=98=8E=E7=BB=86=E5=BC=B9=E5=B1=82?= =?UTF-8?q?=E4=BC=98=E5=85=88=E5=B1=95=E7=A4=BA=E9=92=B1=E5=8C=85=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E5=A4=B1=E8=B4=A5=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - walletDetailsError 改为 mudPointBalanceError || rechargeError,避免充值弹层原始错误串(network down)泄漏到顶栏明细 - 单测补齐 AuthGate 挂载时的那次余额读取,并在 hover 后等待错误文案提交(弹层先渲染 loading 态) - 两个原本失败的用例(ImageCanvasEditorView / createProfileWalletStore)恢复通过 --- .../image-editor/ImageCanvasEditorView.test.tsx | 10 +++++++++- src/components/image-editor/ImageCanvasEditorView.tsx | 4 +++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index a29a45263..9638e2504 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -708,6 +708,12 @@ describe('ImageCanvasEditorView', () => { getPlatformProfileRechargeCenterMock.mockRejectedValue( new Error('network down'), ); + // 真实链路里 AuthGate 挂载 usePlatformWalletLifecycle 时会触发首次余额读取;本用例独立渲染 + // ImageCanvasEditorView,需手动补上这一步。否则 store 停在 idle,入口把它当成 loading 而 + // 跳过 hover 触发的明细请求,弹层会永远停在「余额读取中」。 + await act(async () => { + await usePlatformWalletStore.getState().onWalletBalanceMayHaveChanged(); + }); render( { const details = await screen.findByRole('dialog', { name: '会员与泥点账户详情', }); - expect(within(details).getByText('泥点明细读取失败')).toBeTruthy(); + // 详情弹层打开即渲染 loading 态,读取失败的 rejection 在下一个微任务才提交; + // 必须等待错误文案出现,否则同步 getByText 会稳定命中的是「余额读取中」。 + expect(await within(details).findByText('泥点明细读取失败')).toBeTruthy(); expect(within(details).queryByText('充值中心响应缺少泥点余额')).toBeNull(); expect(within(details).queryByText('永久泥点')).toBeNull(); expect(within(details).queryByText('每日免费泥点')).toBeNull(); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index e417b1dee..ff65cfe2b 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -2527,7 +2527,9 @@ export function ImageCanvasEditorView({ walletBreakdown: mudPointBalance, isWalletBalanceLoading, isWalletDetailsLoading: isLoadingRechargeCenter, - walletDetailsError: rechargeError || mudPointBalanceError || null, + // 明细弹层展示的正是钱包 store 的 breakdown;其读取失败文案优先于充值弹层的原始报错, + // 避免把「读详情」的原始错误串(如 network down)泄漏到顶栏明细里。 + walletDetailsError: mudPointBalanceError || rechargeError || null, currentUser: authUi?.user, assetExportStatus, isExportingAssets,