Feat/陶泥儿会员订阅方案 #588

Merged
k88936 merged 240 commits from feat/pricing-plan into master 2026-10-06 10:48:46 +08:00
Member

首充优惠删除了
每日免费泥点暂时没删, 改为前端得知每日免费泥点为0的时候隐藏文案
之前的会员删除了, 没有迁移
close #586

首充优惠删除了 每日免费泥点暂时没删, 改为前端得知每日免费泥点为0的时候隐藏文案 之前的会员删除了, 没有迁移 close #586
k88936 added 13 commits 2026-10-02 15:08:53 +08:00
- 新增【技术设计】泥点三池与会员计费后端设计-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 <junie@jetbrains.com>
- 新增 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 <junie@jetbrains.com>
- 新增 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 <junie@jetbrains.com>
- 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 <junie@jetbrains.com>
- 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 <junie@jetbrains.com>
- 新增 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 <junie@jetbrains.com>
- 把 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 <junie@jetbrains.com>
- 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 <junie@jetbrains.com>
- spacetime-module:删除 src/runtime/profile.rs,它未被 src/runtime.rs 挂载(真正生效的是 runtime/active/profile.rs)
- 避免设计文档与实现指向失效文件,也避免后续删列改动误落两份

Co-authored-by: Junie <junie@jetbrains.com>
- 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 <junie@jetbrains.com>
- 删除 RuntimeProfileMembershipTier 枚举与会员权益快照/记录。
- 会员快照与记录的 tier 改为 plan 枚举,新增 cycle_index / cycle_count / cycle_kind。
- 充点商品快照/记录与配置快照/记录移除 tier 及四个 membership_* 字段。
- 充值中心快照/记录改用 membership_plans,取代 membership_products 与 benefits。
- 充点商品重播种为六档并取消首充赠送,删除会员商品构造与档位查表 helper。
- 后台充值商品 upsert 只接受泥点商品,会员商品改由会员计划目录维护。
This reverts commit 103f3b548a.
feat(会员): 会员档位目录随充值商品配置播种
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m23s
Project CI / Backend tests (pull_request) Failing after 28s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m56s
Project CI / Frontend tests (pull_request) Successful in 2m59s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m0s
Project CI / Native shell tests (pull_request) Successful in 7m6s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 11m24s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 13m27s
71c778ccee
- 新增 ensure_default_profile_membership_plan,表为空时按会员目录种子整表播种。
- 播种入口挂在 ensure_default_profile_recharge_product_config,表非空时保留后台改价。
- 设计文档 §10 改为「已确认输入 + 实施状态」,记录三项拍板结论。
- 设计文档补充实施约束:module-runtime 与 spacetime-module 相互依赖,领域层清退必须与 schema 变更同批提交。
- 设计文档列出待落地清单(会员遗留清退、三池 DTO、报价接口、充点 6 档、前端同步)。
Author
Member
  • 1. civil_calendar.rs:57 weekday_from_date_key 存在 i64 溢出(bug · low)

  • 现状实现:(date_key + 3).rem_euclid(7) as u8 + 1,先做 date_key + 3。

  • 问题:该函数已 pub 并在 crate 根 re-export。date_key 接近 i64::MAX 时中间加法在 debug 构建 panic、release 构建静默回绕得到错误星期。现有调用方只传 2000..2100 的有界值,当前是潜在缺陷。审查意见属实。

  • 建议修复:先取模再加偏移,避免中间加法。已按建议改为 (date_key.rem_euclid(7) + 3).rem_euclid(7) as u8 + 1。

  • 提交:603ecb088 修复(计费): 星期换算避免 i64 溢出

  • 2. domain.rs:1263-1265 RuntimeProfileMembershipPlan 文档与实现不符(documentation · low)

  • 现状实现:枚举只派生 PartialEq/Eq,没有 PartialOrd/Ord;真正的档位高低取自目录 rank(runtime_profile_membership_plan_rank)。

  • 问题:文档却写「变体顺序即排序基准」,会误导后来者以为可以直接 a < b 或以为声明顺序是排序契约。审查意见属实。

  • 建议修复:二选一——派生 Ord 并与 rank 对齐,或改写文档指向目录 rank 是唯一排序来源。选后者(非破坏性),并注明「当前声明顺序恰与 rank 一致,但顺序不构成契约」。

  • 提交:f81006465 文档(计费): 澄清档位排序以目录 rank 为准

  • 3. lib.rs 通配 re-export 摊平低层原语(maintainability · low)

  • 现状实现(改前):pub use civil_calendar::*;(第 4 行)与 pub use membership::*;(第 19 行),连同 application::*、domain::* 等把大量符号摊到 crate 根(审查给的 3-4 行号不精确,membership 的通配在第 19 行)。

  • 问题:低层公历原语(civil_from_days、days_from_civil、weekday_from_date_key…)和目录内部件都成了 module_runtime 公开 API 的一部分,模块继续增长后有命名冲突与「意外公开内部细节」的风险。审查方向合理。

  • 已做(按你的要求:自己重构、不动其它 crate):

    • civil_calendar 去掉通配,只保留 pub mod civil_calendar; 命名空间;crate 内引用早已是 crate::civil_calendar::{...} 显式路径,无一行需要改。
    • membership 改为策展式 pub use membership::{...},只重导出真正对外的会员契约符号(quote_runtime_profile_membership_upgrade、RuntimeProfileMembershipPlanSnapshot / RuntimeProfileMembershipPlanRecord、RuntimeProfileMembershipUpgradeQuoteInput、membership_cycle_window / membership_expires_at / beijing_local_micros、目录与商品 id 解析、MEMBERSHIP_UNLIMITED_CONCURRENCY 等),其余留在 membership:: 命名空间下。
    • spacetime-module / spacetime-client / api-server 一行未改;cargo check --workspace --all-targets 通过,证明现有引用全部仍在导出白名单内。
    • 未动 llm_billing::*、agc_models::*、application::* 等:它们本身就是 crate 的领域 / 应用契约,不属于本次「摊平低层原语」的问题面。
  • 提交:f09701f6d 重构(计费): module-runtime 去掉通配 re-export 改为策展导出

  • 4. upgrade.rs:147 报价函数不校验数值入参(bug · high,按审查描述)

  • 现状实现(改前):quote_runtime_profile_membership_upgrade 只用 check_..._allowed 校验档位 / 周期,不做数值校验:remaining_full_months = cycle_count.saturating_sub(cycle_index);cycle_index 允许 0 或大于 cycle_count;cycle_count 不校验是否等于 target.cycle_kind.cycle_count();now_micros 不受限于本期窗口。

  • 问题:cycle_index = 0 会让年付 remaining_full_months = cycle_count(多算一个月);cycle_index > cycle_count 被 saturating_sub 夹成 0(少收);cycle_count 与周期类型不一致会直接改价。

  • 对审查结论的更正:审查说这些值「可能来自客户端输入」并不准确。生产入口 membership_upgrade_quote_input(spacetime-module 内部)是从 profile_membership 行构造入参,cycle_index / cycle_count 取自库内 max(1) 后的行字段(写入端 cycle_count = cycle_kind.cycle_count()),并非 HTTP 直传;RuntimeProfileMembershipUpgradeQuoteInput 虽是 pub 且可 serde,但当前没有任何「把它当请求体反序列化」的入口。故现状是「内部调用方的不变量假设」,而非远程可利用缺陷。

  • 已做:新增 RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput(wire token invalid_quote_input),并在报价函数开头加 validate_runtime_profile_membership_upgrade_quote_input:cycle_index >= 1 && cycle_index <= cycle_count、cycle_started_at <= cycle_resets_at、now >= cycle_started_at,命中即拒绝,不再被 saturating_sub 静默夹取。新增回归测试 invalid_numeric_input_is_rejected。该 rejection 目前只经 as_str() 对外,未波及其它 crate 的映射。

  • 刻意未做的两项(保留给你,理由如下):

    • cycle_count == target.cycle_kind.cycle_count():本轮已由第 12 项从根上解决——cycle_count 不再是入参,改由 target.cycle_kind.cycle_count() 推导,二者天然一致,无需再校验。
    • now <= cycle_resets_at:现有契约刻意允许「已过期报价」按剩余比例 0 返回(测试 remaining_ratio_is_zero_at_or_after_reset 正是覆盖这条),强制上界会改行为,故未动。
  • 提交:83dbd0cc7 修复(计费): 升级报价拒绝越界数值入参

  • 文档同步:3eaae417b 文档(计费): 补充升级报价数值入参自检说明(ADR §3 领域数据结构)

  • 5. upgrade.rs:154-155 saturating_sub 掩盖目录配置错误(maintainability · medium)

  • 现状实现:price_delta_cents 与 points_delta 都用 saturating_sub;而 check_runtime_profile_membership_upgrade_allowed 只比较 rank,不校验「高 rank 是否真的更贵 / 更多点」。

  • 问题:若某档位 rank 更高但价格更低或每期泥点更少(后台可改价格与每期泥点,rank 不可改),补差会被静默算成 0,而不是报错。审查意见属实。

  • 建议修复:审查给两条路——(a) 运行时强制不变量(price_delta == 0 / points_delta == 0 时返回错误并映射 4xx);(b) 把目录必须满足的单调不变量写进文档。

  • 已做(非破坏性):在 catalog.rs 模块文档写明「按 rank 升序,月价 / 年价 / 每期泥点必须严格递增」,并新增回归测试 enabled_plans_increase_in_price_and_points_with_rank 固化种子目录不变量。

  • 仍留给你:若想防御「后台把高 rank 档位改得更便宜」,方案 (a) 需新增 RuntimeProfileMembershipUpgradeRejection 变体并改报价 / 下单的 4xx 语义,属行为变更,未做。

  • 提交:8e1471d7a 测试(计费): 固化会员目录价格与泥点随 rank 递增的不变量

  • 6. catalog.rs:140 快照解析未校验可购买(maintainability · low)—— 核实为误报 / 过时

  • 现状实现:build_runtime_profile_membership_plan_snapshot 已用 enabled: row.enabled && row.plan.is_purchasable() 把「可购买」编码进快照;RuntimeProfileMembershipPlanSnapshot.enabled 的文档已明确「Normal 与后台下架档位为 false」。下单路径 resolve_enabled_profile_recharge_order_product 还额外校验 row.enabled && plan.is_purchasable()。

  • 问题:审查称「解析器不检查 is_runtime_profile_membership_plan_purchasable,直接调用就会给下架档位收费」,与现状不符——可购买性已被快照的 enabled 字段承载,且真实下单入口另有校验。不存在该现状缺陷。

  • 结论:现有设计已覆盖风险,无需修改。

  • 可选(留给你):若想要 defense-in-depth,可让解析器返回 Result。这属 API 变更,会改签名与调用方,由你决定。

  • 7. catalog.rs:176 runtime_profile_membership_plan_rank 用 unwrap_or(0)(maintainability · low)—— 已按 (a) 落地

先说 rank 到底是什么(回答你的 WTF)

  • rank 是会员目录表 profile_membership_plan 里的一列整数,当前种子值:Normal=0、Starter=1、Plus=2、Pro=3、Max=4。
  • 它唯一的作用是回答一个问题:「目标档位是不是比当前档位更高?」——判定在 check_runtime_profile_membership_upgrade_allowed:target.rank > current.rank 才算升级,否则返回 NotUpgrade。
  • 为什么不直接比较枚举大小?因为旧实现用 RuntimeProfileMembershipTier 枚举的声明顺序当排名,历史上枚举顺序或别名一改就会悄悄改掉计费语义。现在把「高低」外置成数据(rank),枚举本身刻意不派生 Ord,避免有人再写 a < b;并且 rank 后台改不了(§5.6:rank 与 plan 不进后台 upsert 请求),只有代码里的目录种子能定,所以它是一份不会随运营配置漂移的稳定基准。
  • 注意区分三个东西:rank(升级比较基准,代码内固定)、sort_order(目录展示排序,后台可改)、enabled(是否在售,后台可改)。

问题

  • 实现是 resolve_runtime_profile_membership_plan_row(plan).map(|row| row.rank).unwrap_or(0)。目录缺行时返回 rank = 0,而 0 正好等于 Normal,于是缺行会被当成「目标不高于当前」→ 误报 NotUpgrade,掩盖「目录缺行」这个真正的问题。
  • 同样的写法在 spacetime-module 里也有一份内联拷贝:upsert_profile_membership_plan_record(profile.rs:8150-8152)写 rank 时是 resolve_..._row(...).map(|row| row.rank).unwrap_or(0)。
  • 务实评估:目录是编译进代码的固定种子,覆盖全部枚举变体,None 分支实际不可达 → 属理论风险;但 unwrap_or(0) 把「缺失」编码成一个合法值,等于把编程错误伪装成业务结果,值得改掉。

已做(方案 (a):把解析改成可失败)

  • runtime_profile_membership_plan_rank 返回类型由 u8 改为 Option<u8>,实现简化为 resolve_..._row(plan).map(|row| row.rank),缺行即 None。

  • plan_rank_follows_catalog_order 测试断言同步改成 Some(0) / Some(2) / Some(4)。

  • 全仓检索确认该函数此前只被自身测试与文档链接引用(lib.rs 仅是 re-export、domain.rs 是文档链接),故改签名不改任何生产行为。

  • 按你「不动其它 crate」的要求,spacetime-module 里的内联拷贝(profile.rs:8150)本轮未动;如果你也想把它改成显式断言 / 显式错误,那是跨 crate 改动,需要你放行。

  • 文档同步:ADR §3 的访问器签名改为 -> Option<u8> 并说明缺行返回 None。

  • 提交:047e6d72e 重构(计费): 会员档位 rank 解析改为可失败返回

  • 文档提交:51c5784c4 文档(计费): 同步会员档位 rank 可失败返回说明

  • 8. cycle.rs:25-27 beijing_local_micros 文档返回说明错误(documentation · low)

  • 现状实现:日期非法时 return 0(Unix epoch)。

  • 问题:文档写「本地日期不存在则返回原值」,与实现相反,会误导调用方以为拿到原值且无错误信号。审查意见属实。

  • 建议修复:改文档说明返回 0,或改签名为 Option<i64>/Result。选改文档(非破坏性)。

  • 提交:5ab6f161d 文档(计费): 修正北京本地时间换算的返回值说明

  • 9. civil_calendar.rs:39 civil_from_days 存在 i64 溢出(bug · medium)

  • 现状实现:let z = date_key + 719_468;。

  • 问题:函数已 pub 并 re-export。传入接近 i64::MAX 的 date_key 时 debug panic、release 回绕出错误日期。审查意见属实。

  • 建议修复:改 saturating_add。

  • 提交:8b8213f0c 修复(计费): 公历反算避免 i64 溢出

  • 10. catalog.rs:64 每次解析都重建目录(performance · low)—— 作者拍板 wont fix,仅加 TODO 注释

  • 现状实现:runtime_profile_membership_plan_catalog() 每次 vec![...],每行 String 都是新分配;resolve_..._row / ..._rank / ..._purchasable 都经由它,单次报价会重建数次。

  • 问题:纯种子数据却每次分配,属可优化项;但只有 5 行,实际开销可忽略。

  • 建议修复:用 std::sync::LazyLock<Vec<...>>(或 OnceLock)缓存一次;若要彻底零拷贝,可把返回值改为 &'static [...]。

  • 处理(按你要求 leave):不改成 LazyLock / &'static,只在函数体首行加 // TODO(membership-catalog): … 注释,记录「只有 5 行、开销可忽略、按作者意见暂不修复」。

  • 提交:bc3d8dc7d 注释(计费): 记录会员目录重建暂不缓存的原因

  • 11. cycle.rs:24 time_of_day_micros 未做范围校验(bug · low)

  • 现状实现:只校验年月日,time_of_day_micros 直接进入 days_from_civil(...) * DAY + time_of_day − offset。

  • 问题:传入 >= PROFILE_RUNTIME_DAY_MICROS 或负值会被顺延 / 回退到相邻日期,而不是按契约拒绝。虽是 pub 原语,当前调用方都传 [0, DAY_MICROS)。审查意见属实。

  • 建议修复:增加 [0, PROFILE_RUNTIME_DAY_MICROS) 校验,越界返回 0。已按此实现,并同步把文档改为「日期或当日时刻越界都返回 0」。

  • 提交:58a79a2e4 修复(计费): 北京本地时间换算校验当日时刻范围

  • 12. upgrade.rs:36-37 cycle_count 取调用方原值、未与周期类型对齐(bug · medium)—— 已按「总期数不由入参提供」落地

  • 1. .prettierrc.json 里针对 packages/shared/src/contracts/generated/**/*.ts 的覆盖配置已是死配置(该目录已被 .prettierignore 忽略)。已删除该 overrides。提交 f631a2f06。

  • 3. 充值商品页不再区分 entry.kind,历史会员商品行会被误渲染成「0 + 0 泥点」且保存必被拒。已在 refreshProducts 里过滤出 kind === 'points',会员档位统一到「会员档位」页维护。提交 5fabe6f2f。

  • 4. membershipPlanResult 未随登出 / 换管理员登录清空,会显示上一位管理员「最近保存」面板。已在 clearSession 与 handleLogin 两处补 setMembershipPlanResult(null)。提交 d49c52735。

  • 5. 会员档位下拉选中目录里不存在的档位时,表单只更新 plan,其余字段沿用上一档。已新增 clearForm,无匹配行时标题/价格/每期泥点/模型权限/并发/上下架/排序全部复位并清空 rank。提交 848f0b54c。

  • 7. 钱包 store 已移除 applyLegacyWalletBalanceSnapshot,但 apps/ai-game-creator-shell/tests/walletStore.test.ts 仍断言它,CI 必挂。已更新断言键列表。提交 ac5a03bf7(npx vitest run apps/ai-game-creator-shell/tests/walletStore.test.ts 14/14 通过)。

  • 8. packages/shared/src/utils/mudPoints.ts 的 resolveProfileMudPointBalanceView 未兜底 dailyFreeResetPoints,后端缺字段时界面显示「每天重置为 NaN 泥点」。已补 ?? 0。提交 40e4e6e38。

  • 9. 同上文件固定 Asia/Shanghai 格式化时 hour12: false 在部分引擎会落到 h24,午夜渲染成 24:00。已改为 hourCycle: 'h23'。提交 40e4e6e38。

  • 16. module-runtime/src/membership/upgrade.rs:161-162 用 saturating_sub 把倒挂目录静默算成 0 元升级 / 0 泥点。已改为 checked_sub(...).ok_or(InvalidQuoteInput)?,并新增单测 inverted_catalog_price_and_points_are_rejected。提交 92917f57c。

  • 17. AdminMembershipPlanPage.tsx 月价/年价输入 min={1} 会让按设计价格为 0 的 normal 占位档被原生校验挡住。已改为 min={isPurchasable ? 1 : 0}。提交 848f0b54c。

  • 18. module-runtime/src/commands.rs 非可购的 normal 占位档原样透传 model_access / concurrent_job_limit,与文档注释不符。已改为固定取目录种子基线(resolve_runtime_profile_membership_plan_row)并同步注释(commands.rs:468-485)。⚠ 这是行为收紧:走后台改「非会员基线」的入口现在无效,请你确认是否认可;不认可可回退提交 119eb7c8b 中这段。提交 119eb7c8b。

  • 19. InvalidRechargeProductTier 文案「会员商品 tier 无效」误导(现在用于两种与 tier 无关的情况)。已拆成 InvalidRechargeProductKind(「充值商品类型无效:会员商品已迁移到会员档位目录」)与 InvalidRechargeProductFields(「泥点商品不支持 duration_days 与 bonus_points 字段」)。提交 119eb7c8b。

  • 20. module-runtime/src/membership/cycle.rs:44 的 as i32 截断 — 复核结论:误报,无需改代码。year 来自 beijing_local_date_parts(i32),months: u32,可达到的 total_months.div_euclid(12) 上界约 6.5e8,远小于 i32::MAX,要截断需要 i32 无法表达的年份。review 给的 clamp 补丁也解决不了它声称的「窗口越界」——真要防应校验 index 上界。

  • 21. 默认充点档迁移不认得上一版迁移留下的行(停用行 bonus_points 被保留成面额、180/300/680 变成「首充加赠」行)。已放宽停用行判定并新增「首充加赠」分支,同时把测试改成真实迁移后形态。提交 6d04f88f4。

  • 10. server-rs/crates/spacetime-module/src/runtime/active/profile.rs 管理接口不校验调用方身份(security / high)。修复前:admin_list_profile_membership_plans、admin_upsert_profile_membership_plan、get_profile_membership_upgrade_quote 等配置管理 procedure 只做入参字符串校验,完全信任入参里的 admin_user_id / user_id,不像 mark_profile_recharge_order_paid_and_return 那样先 require_editor_generation_runtime_service_identity(tx, caller);除 API server 外若有客户端能直连模块,就能改会员价格目录(现在价格的唯一来源)或查任意用户报价。已修:10 个配置管理 procedure 全部补上模块级 caller 校验(let caller = ctx.sender(); ctx.try_with_tx(|tx| require_editor_generation_runtime_service_identity(tx, caller)? /* 原逻辑 */),写法与 settle_llm_router_quota_and_return 一致),现在只有持有 editor_generation_pricing_config.writer_identity 的 api-server 服务身份可调用。提交 e5de57f7f。

  • 11. profile.rs 结算不比对订单金额(high)。修复前:apply_profile_membership_purchase 按「当时目录」重算报价并授予权益,quote.amount_cents 从不与订单金额比较;订单 Pending 期间改价 / 改每期泥点,会出现「按旧价扣款、按新价给权益」或反向不一致。已修:采用本项建议里的「建单时持久化报价并在结算复用」——create_profile_recharge_order_record 在建单时把档位 / 周期 / 每期泥点 / 补点 / 金额 / 价格明细冻结进订单的会员快照列,apply_profile_recharge_purchase 结算只认这份快照、不再回查目录,enabled 检查也一并移除,因此下单后改价 / 下架都不影响已受理订单;历史 Pending 无快照订单仍回退按目录重算旧口径。提交 e5de57f7f。

  • 13. 建单金额用了过期账期(medium)。修复前:resolve_profile_membership_order_amount_cents 直接读库里 profile_membership 行,不像报价 build_profile_membership_upgrade_quote_record 与购买 apply_profile_membership_purchase 那样先刷新账期。问题:会员周期是懒刷新,用户跨过结算边界却没触发刷新时,库里的 cycle_resets_at 已是过去时间、cycle_remaining_points 还是上一期旧值;年付补差要按「本期还剩几个完整月」折算,用过期的账期数据算就会算错,建单金额与预览、结算三处不一致。已修:新增只读投影 project_profile_membership_cycle_at(profile.rs:9250),用与刷新路径完全相同的推进数学 advance_profile_membership_cycle_to 在内存里把会员行投射到建单时刻,再据此算补差,全程不写库。为什么不是「更新 cycle_index」:cycle_index 是账期窗口与账本元数据的持久锚点,必须落库;建单只需要「假如现在刷新会得到什么」的投影值,没必要真的推进已存储的索引——所以改成只读计算,而不是把「建单」变成会推进账期的写操作。提交 e5de57f7f。

  • 14. !target.enabled 让 Pending 订单无法支付(medium)。修复前:resolve_profile_recharge_order_product 为「按建单时档位结算」特意跳过 enabled 检查,但 apply_profile_membership_purchase 里 if !target.enabled { return Err(...) } 又直接拒绝;管理员在订单 Pending 时下架该档位,支付回调就会失败,订单永远付不掉。已修:按你选的 (c) 方案——建单时把「档位 + 价格」快照写进订单,结算只认订单快照、完全不看目录 enabled,既不会卡死已付款订单,也便于按订单审计。提交 e5de57f7f。

  • 22. 后台 upsert 不保证 rank→价 / 点单调(medium)。修复前:写入侧只拒 0 值,不校验跨档排序不变量;一旦后台把高档位定得比低档位便宜 / 每期泥点更少就能落库,报价侧虽会报错(92917f57c),但目录本身已被污染。已修:新增 validate_runtime_profile_membership_plan_row_against_catalog(module-runtime/src/membership/catalog.rs),在 upsert_profile_membership_plan_record 写库前(profile.rs:8286)把本次这一行与目录其余每一行比较,要求 rank 严格递增且月价 / 年价 / 每期泥点随 rank 严格递增,违反返回 InvalidMembershipPlanOrder;因为只看「本次这一行 vs 其余行」,历史脏目录仍能逐档改回。提交 e5de57f7f。

  • 12. profile.rs:8169 报价 procedure 会写状态(medium)。修复前:get_profile_membership_upgrade_quote 注释写「只读」,但 build_profile_membership_upgrade_quote_record 在 profile.rs:8182 调用 refresh_profile_wallet_expiring_points,会过期会员、推进账期、改钱包流水;单纯预览报价就改会员 / 钱包状态,还可能提前消耗用户的懒刷新。已修:去掉该写库刷新,改用 e5de57f7f 新增的只读投射 project_profile_membership_cycle_at(profile.rs:9265)在内存里把账期推进到当前时刻再报价,全程不写库、不发账本,仍与建单 / 结算同口径,报价接口名副其实只读。提交 90e520fdb。

  • 3. PlatformProfileRechargeModal/index.tsx:1436 —— 硬编码「¥1 = 10 永久泥点」。
    现状:充点档位完全由后台 profile_recharge_product_config 配置,但弹窗底部这句汇率文案写死在组件里,是本页唯一不跟随目录的数据。
    问题:后台改价或改点数后文案与实际商品不一致,误导用户。
    修复:按你的决定直接删掉这句硬编码文案,不做契约变更。提交 c165a032c(fix(充点弹窗): 删除硬编码的充值汇率文案)。

  • 4. membershipCopy.ts:12-15 —— 客户端重复定义后端事实。
    现状:RECOMMENDED_MEMBERSHIP_PLAN = 'plus' 与 UNLIMITED_CONCURRENT_JOB_LIMIT = 128 都写在客户端;128 需手工与后端 MEMBERSHIP_UNLIMITED_CONCURRENCY 对齐,'plus' 是纯运营推荐、后端目录没有对应字段。
    问题:后台调整档位或并发上限后 UI 会静默失真。
    修复:在 HTTP 层 ProfileMembershipPlanResponse 增加由后端派生的 recommended 与 unlimitedConcurrency 两个布尔(recommended 取自 PROFILE_MEMBERSHIP_RECOMMENDED_PLAN,unlimited_concurrency 由 is_unlimited_concurrency 判定);前端 ProfileMembershipPlanRecord 契约补上这两个字段,membershipCopy.ts 删除 RECOMMENDED_MEMBERSHIP_PLAN / UNLIMITED_CONCURRENT_JOB_LIMIT / isUnlimitedConcurrency,并发文案改读 plan.unlimitedConcurrency,充值弹窗推荐标签改读 plan.recommended。说明:为避免 procedure 层绑定大改,只在 HTTP DTO 上暴露标志,procedure 级 RuntimeProfileMembershipPlanRecord 未动;后台 DTO 现在仍有本地 128 常量,属残留重复,未在本项扩围。提交 4c6af9f19。

  • 6. module-runtime/membership/upgrade.rs:136-139 —— 报价校验放过已过账期。
    现状:validate_runtime_profile_membership_upgrade_quote_input 原先只拒绝 now < cycle_started_at,允许 now >= cycle_resets_at;此时剩余比例为 0,但 remaining_full_months = cycle_count − cycle_index 仍按调用方传入的旧 cycle_index 计算,年付会多收整月。
    问题:历史待支付订单重算路径把库里的旧行直接喂进报价函数、不投射账期,会触发「未推进的 cycle_index + 已过期窗口」。
    修复:按你的决定采纳评审建议,把 now >= cycle_resets_at 也判为非法报价入参,报价函数直接拒绝已过账期窗口。提交 4621f76a5。

  • 21. spacetime-module/profile.rs:9875-9878 —— 升级订单跨到期被少收。
    现状:订单创建时按补差冻结 amount_cents;支付确认时若会员已到期,active_row 为 None,代码走 apply_profile_membership_fresh_purchase,按冻结档位重新起算并发放整期泥点。
    问题:用户可在有效期内用很小的补差价建单,等会员过期后再支付,等于用零头买一个完整周期。
    修复:在 apply_profile_membership_purchase 的 Upgrade 分支、active_row 为 None 时,用当前目录价做校验:check_expired_membership_upgrade_settlement,补差金额不足以覆盖新购价时拒绝支付并提示「会员已到期,请按新购价格重新下单」。提交 f9b0cf786。

  • 22. spacetime-module/profile.rs:455-456 —— tier → plan 无迁移。
    现状:schema 删掉 tier / cycle_period_days,新增 plan(默认 Normal)与 cycle_kind(默认 Monthly),没有旧档位映射。
    问题:若库中存在历史会员行,会被读成 Normal,续期时静默丢权益。
    处理:按你的决定「会员侧未上线、无存量行,可安全删除」,本次不做回填迁移。已确认代码里只剩注释提到旧 tier / cycle_period_days(domain.rs:1234、profile.rs:454),没有现役字段或调用方。发布前仍需用一个真实库确认 profile_membership 为空;若发现存量行再评估迁移。

  • 23. spacetime-module/profile.rs:9147-9148 —— 只读路径写库。
    现状:membership_plan_row / membership_plan_records 原先会调用 ensure_default_profile_membership_plan,在表为空时插入种子行,而它们同时服务于标注「只读」的升级报价 procedure 与充值中心快照。
    问题:报价请求会写库并初始化目录,与只读契约不符。
    修复:按你的决定把目录种子显式挪到模块初始化生命周期:新增 #[spacetimedb::reducer(init)] init_profile_membership_plan_catalog,只在数据库首次创建时整表播种;membership_plan_row / membership_plan_records 去掉懒播种,ensure_default_profile_recharge_product_config 也不再顺带初始化会员目录。存量数据库此前已由懒播种写入目录行,新建数据库由 init 播种。提交 6f0d773dd。

  • 25. api-server/admin_recharge.rs:163-168 —— GET 用户详情会改用户钱包。
    现状:admin_get_user_detail(GET)内部复用 get_profile_recharge_center,其快照构建会调 refresh_profile_wallet_expiring_points,发放每日免费泥点、推进 / 重置会员账期、写钱包余额与账本。
    问题:后台只是打开用户详情就可能修改该用户的钱包与会员状态并插入账本行,审计上说不清是谁改的。
    修复:新增纯读 procedure get_profile_membership_snapshot_and_return(不刷新钱包 / 账期、不写库,仍校验 service identity),在 module-runtime 增加 RuntimeProfileMembershipGetInput / RuntimeProfileMembershipSnapshotProcedureResult 与入参构造器,spacetime-client 增加 get_profile_membership_snapshot facade、From 转换与结果映射并同步生成绑定;build_profile_membership_snapshot 拆成「写库路径(先 refresh)」与「纯读路径(用 project_profile_membership_cycle_at 只读投影)」两条,共用 from_row 投影;admin_get_user_detail 改用只读快照。提交 43f0c5112。

  • 27. spacetime-module/profile.rs:9408-9411 —— 升级金额使用客户端时间。
    现状:建单的补差金额用 created_at,来自 create_profile_recharge_order_and_return 的入参 created_at_micros(经 HTTP 层客户端可传)。
    问题:客户端可把 created_at_micros 设到 expires_at 前一刻,把剩余比例压到 0、金额压到接近 0,再按该金额支付并在结算时拿到目标档位整期泥点。
    修复:按你的决定彻底移除客户端时间参数(forbid):RuntimeProfileRechargeOrderCreateInput 删除 created_at_micros,build_runtime_profile_recharge_order_create_input(user_id, product_id, payment_channel) 同步收窄;module 侧改用 ctx.timestamp 作为 created_at_micros / order_id;HTTP 层删除 current_unix_micros() 与调用参数;spacetime-client facade、mapper、生成绑定同步。提交 3e2ab3cf7。

  • 28. spacetime-module/profile.rs:9864-9874 —— 待支付升级订单未校验冻结前状态。
    现状:结算时对当前行直接 apply_profile_membership_upgrade,不再比对 settlement.plan_before / cycle_kind_before。
    问题:若用户先付了更高的升级单,或会员到期后按不同周期重新购买,旧订单落地会无条件覆盖 plan / cycle_kind / cycle_count,却保留当前 expires_at / started_at,可能把 Max 降回 Pro,或产生 Yearly + 一个月到期的矛盾行。
    修复:按你的决定,在应用升级结算前校验 row.plan == settlement.plan_before 且 row.cycle_kind == settlement.cycle_kind_before,不等则拒绝并提示「会员状态已变化,请重新下单」;api-server 把该文案纳入会员域错误(400)映射。提交 3031f5887。


其余自动修复项(此前已提交)

  • 1. adminApiTypes.ts:978 —— 订单变更类型收窄。已核实
    现状:changeKind: 'purchase' | 'upgrade' | string。问题:| string 让字面量联合退化成 string,拼写错误或未来新增的 downgrade 会静默流入展示与分支;后端 RuntimeProfileMembershipChangeKind 只会输出 purchase / upgrade。
    修复:删掉 | string,非法取值改由编译期拦截。提交 cd0cbacf1。

  • 2. adminApiTypes.ts:1056-1057 —— 会员状态收窄。已核实
    现状:AdminProfileMembershipPayload.status: string。问题:后端 RuntimeProfileMembershipStatus 只有 normal / active,string 让未知状态直接进入 MembershipSection 展示。
    修复:收窄为 'normal' | 'active' 并补注释。提交 7dd26b9cd。

  • 5. membershipCopy.ts:87-89 —— isUnlimitedConcurrency 无人调用。已核实
    现状:该函数已导出但全仓没有调用方,两个并发文案函数各自内联 >= UNLIMITED_CONCURRENT_JOB_LIMIT。问题:哨兵判定存在三份实现,改一处容易漏另一处。
    修复:两个文案函数改为委托 isUnlimitedConcurrency,判定只留一份。提交 cf6391962。(第 4 项后该函数已随硬编码常量一并删除。)

  • 7. AdminMembershipPlanPage.tsx:442-453 —— parseInt 静默截断。已核实
    现状:parseInteger 用 Number.parseInt(value, 10)。问题:type="number" 里合法的 "1e2" 会解析成 1、"1.9" 变成 1,价格 / 泥点 / 并发会被静默存错。
    修复:两个解析函数都改为 Number(value) + Number.isSafeInteger。提交 bfd96a7c2。

  • 8. AdminMembershipPlanPage.tsx:114-117 —— 每期泥点缺少提交前校验。已核实
    现状:只有月价 / 年价 / 并发有友好校验,periodPoints 把空值变成 0 后直接提交。问题:后端 validate_runtime_profile_membership_plan_row_against_catalog 以 InvalidMembershipPlan 拒绝,管理员要点完确认弹窗才收到笼统目录错误。
    修复:非 normal 档位新增「每期泥点必须是大于 0 的整数」,解析结果复用到请求体。提交 58e07ba28。

  • 9. useAccountWallet.ts:395-398 —— 报价请求竞态。已核实
    现状:只用 rechargeLifecycle 与当前用户判定。问题:先点 A 档再点 B 档时,A 的 finally 会在 B 仍 pending 时清掉 loading,确认页短暂回落到未打折原价且提交按钮不可用。
    修复:新增 membershipQuoteRequestRef 序号令牌,只有最新请求能写报价 / 错误 / loading。提交 5361d8963。

  • 10. useAccountWallet.ts:435-438 —— 会员下单传伪造字段。已核实
    现状:buyRechargeProduct(product: ProfileRechargeProduct),会员下单手写 priceCents: 0、kind: 'membership' 等一整套假数据,实际只用到 productId。问题:类型上给了「前端能定价」的假象。
    修复:签名收窄为 Pick<ProfileRechargeProduct, 'productId' | 'kind'>。提交 44952704b。

  • 11. scripts/admin-web-fake-api.mjs:458-460 —— 缺少会员档位 mock 路由。已核实
    现状:假后端没有 /admin/api/profile/membership-plans,请求落到末尾 return {}。问题:新后台页拿到 undefined 的 entries,只显示列表错误,POST 也静默返回 {}。
    修复:新增 GET 列表与 POST 回显。提交 8cfa5a45a。

  • 12. scripts/admin-web-fake-api.mjs:462-463 —— 期号越界。已核实
    现状:月付样例 cycleKind: 'monthly' + cycleIndex: 2 + cycleCount: 1。问题:后端约定 1 <= cycle_index <= cycle_count,本地预览会渲染「第 2/1 期」。
    修复:cycleIndex 改为 1。提交 40c754ee0。

  • 13. PlatformProfileRechargeModal/index.tsx:1378-1385 —— 点击触发两次下单。已核实
    现状:外层 <div onClick> 包着已经带 onCheckout 的套餐卡按钮。问题:点 CTA 时事件冒泡,openMembershipCheckout 执行两次。
    修复:删掉外层 div 的 onClick。提交 5f31c8c9d。

  • 14. module-runtime/membership/upgrade.rs:203-206 —— ceil_div 除零被静默吞掉。已核实
    现状:分母为 0 时返回 0。问题:死防御,一旦常量被重构,程序员错误会变成「合法」的 0 元报价。
    修复:加 debug_assert!(denominator != 0),保留 release 下的 0 兜底。提交 73b53a9dd。

  • 15. module-runtime/membership/upgrade.rs:135 —— 零时长账期窗口未被拒绝。已核实
    现状:窗口校验用 <。问题:cycle_resets_at == cycle_started_at 能通过校验,金额只按剩余整月算。
    修复:改为 <= 并补注释。提交 adfae5ade。

  • 16. api-server/runtime_profile.rs:263-270 —— 会员业务拒绝被当成 502。已核实
    现状:map_runtime_profile_client_error 只把兑换码的 7 条文案提升为 400,其余 SpacetimeClientError::Procedure 一律 502。问题:正常客户端错误都变成 502。
    修复:新增 is_runtime_profile_membership_domain_error,把会员域文案映射为 400,基础设施错误仍保持 502;同一处也覆盖第 26 项。提交 aca58b6b2。

  • 17. AdminMembershipPlanPage.tsx:325 —— 空标题仍可提交。已核实
    现状:保存按钮只在 isSaving 时禁用。问题:标题为空 / 全空格时后端返回 MissingProductTitle,要点完确认弹窗才看到笼统错误。
    修复:disabled={isSaving || !title.trim()}。提交 a0c48d85a。

  • 18. apps/ai-game-creator-shell/src-tauri/desktop.rs:599 —— 新命令未进 allowlist。已核实
    现状:read_profile_membership_upgrade_quote 经 accountHost.ts 调用,静态扫描解析不到字面量 invoke('...')。问题:check-config.mjs 直接报 missing commands,agc:typecheck 不过。
    修复:在 check-config.mjs 的 allowlist 补上该命令。提交 071cc3284。

  • 19. usePlatformProfileCenterController.ts:1359-1364 —— 报价请求竞态。已核实
    现状:只用 signal 与账号生命周期判定。问题:切 A / B 档或点「重新获取报价」时,慢的旧响应会覆盖新报价并提前把 loading 置 false。
    修复:新增 membershipQuoteRequestRef 序号令牌。提交 3a998e15b。

  • 20. usePlatformProfileCenterController.ts:1392-1402 —— 会员下单传伪造字段。已核实
    现状:与第 10 项同源,平台面板版本也手写一整套假数据。问题:实际只消费 productId(和 kind),priceCents: 0 会让人误以为客户端在定价。
    修复:签名收窄为 Pick<ProfileRechargeProduct, 'productId' | 'kind'>。提交 c1b96f6bf。

  • 24. usePlatformProfileCenterController.ts:400-406 —— 切换账号残留报价状态。已核实
    现状:账号切换的重置 effect 只清 recharge / wallet / referral / rewardCode 等状态。问题:membershipQuote 可能保留上一个账号的报价;isLoadingMembershipQuote 因被中断请求的 finally 不再执行而卡在 true。
    修复:重置 effect 补 setMembershipQuote(null) / setIsLoadingMembershipQuote(false) / setMembershipQuoteError(null),并作废未完成报价令牌。提交 873bd5bee。

  • 26. api-server/runtime_profile.rs:1217-1228 —— 会员档位目录校验失败被当成 502。已核实
    现状:admin_upsert_profile_membership_plan 也走 map_runtime_profile_client_error。问题:管理员把档位改成价格 / rank 倒挂时收到 502。
    修复:与第 16 项同一处提交 aca58b6b2 已覆盖(会员档位… 前缀 → 400)。

  • 1. 会员变更摘要悬空分隔符(bug · low,apps/admin-web/src/pages/AdminRechargeOrderPage.tsx:1311-1314)

    • 现状:formatMembershipCycleKind(change.cycleKindAfter ?? change.cycleKindBefore ?? ''),而 formatMembershipCycleKind('') 会把未知值原样返回,于是摘要被拼成 ${action} ${planPath} / ${cycle}。
    • 问题:cycleKind 缺失时渲染出 新购 Pro / 这种悬空斜杠。
    • 已修复(cf6004718):先取 cycleKindAfter ?? cycleKindBefore,仅在有值时才追加 / ${formatMembershipCycleKind(cycleKind)}。
  • 2. 泥点商品列表为空时表单残留旧值(bug · low,AdminRechargeProductPage.tsx:75-79)

    • 现状:refreshProducts 只在 sortedEntries[0] 存在时 fillForm;过滤后为空就保持上一次表单值(或默认值)。
    • 问题:商品被全部删除后,表单仍带着已删除商品的旧值且保存按钮可点,容易误存一条过期记录。
    • 已修复(5157ec178):抽出 DEFAULT_RECHARGE_PRODUCT_FORM,列表为空时调用 resetForm() 回到新建默认值。
  • 3. 同名整数解析器语义分裂(maintainability · low,AdminProfileWalletConfigPage.tsx:209-215 与 AdminRechargeProductPage.tsx)

    • 现状:两个页面各自定义 parseNonNegativeInteger:账号配置页返回 number | null(非法即拒绝),充值商品页返回 number(负数/非法压成 0);会员档位页还有第三份 parsePositiveInteger。
    • 问题:同名不同契约,后续容易改错一边造成校验漂移;另外旧的 parseInt 会把 '12abc' 截断成 12。
    • 已修复(9f10423ff):在 pageUtils.ts 提供严格版 parsePositiveInteger / parseNonNegativeInteger(Number + isSafeInteger,返回 number | null)与落库宽松版 ...OrZero,三个页面删除本地副本,统一走共享实现。
  • 4. 假后台保存会员档位后回显错档(maintainability · low,scripts/admin-web-fake-api.mjs:768-769)

    • 现状:假后台 POST 不读请求体,统一回显 plans[1](starter)。
    • 问题:本地预览保存非 starter 档位后,表单与列表被重置回 starter,upsert 联调结果失真。
    • 已修复(dec44ec4e):route() 增加 requestBody,POST 时按 plan 找到目标行并用提交字段覆盖后回显;同时抽出 UNLIMITED_CONCURRENCY_THRESHOLD 常量。
  • 5. 旧档位 token 无数据迁移(bug · high,server-rs/crates/module-runtime/src/domain.rs:1262-1272)

    • 现状:RuntimeProfileMembershipPlan::parse 只接受 normal/starter/plus/pro/max,旧 token month/season/year/basic/ultimate 直接返回 None。
    • 复核更新:你已提交 ba451bfc0,把 RuntimeProfileMembershipTier 与旧 tier 列保留下来,schema 改为追加式兼容,避免发布时导出/清库;但运行时不读旧 tier,新 plan 列对存量行仍是默认 Normal。
    • 问题:存量会员行会解码成 Normal,续期时按月额度归零,等于静默降级;migration.rs 当前没有 profile_membership 的 tier → plan 映射分支。
    • 建议:在保持旧列不动的前提下加一次性回填(读旧 tier → 写等价 plan + cycle_kind),可做版本化 reducer 或迁移导入规范化。
  • 6. 会员档位“缺失”时引导原价新购(bug · medium,packages/shared/src/components/PlatformProfileRechargeModal/index.tsx:362-364)— 经你确认前提不成立,非问题

    • 现状:resolveMembershipPlanAction 在 !isActive || !membership || !currentPlan 时返回 开通会员;!membership 在 isActive 为真时确实是死条件。
    • 复核:会员档位是代码内目录的固定枚举,membership_plan_records 返回全部档位行(包括 enabled=false,只标记可购买状态,不删行),前端 currentPlan 由同一份列表按 membership.plan 查找。不存在“后台下架/删除某档位导致当前计划不在目录”的路径,因此 currentPlan 只有在目录整表为空(见 #25)时才为 null。
    • 结论:无实际 bug;!membership 冗余条件可顺手清掉,但无行为影响,未改代码。
  • 7. 升级报价未按周期匹配(bug · medium,PlatformProfileRechargeModal/index.tsx:930-933)

    • 现状:quoteForTarget 只比对 membershipQuote.targetPlan === target.plan。
    • 问题:同档位、不同周期的旧报价或加载中的陈旧报价会被当成命中,弹窗金额/赠送点与提交按钮状态都可能错误。
    • 已修复(251475994):匹配条件补上 membershipQuote.cycleKind === target.cycleKind,周期不一致按“无报价”处理。
  • 8. 报价错误态嵌套三元(style · low,PlatformProfileRechargeModal/index.tsx:1011-1012)

    • 现状:errorState 内联 error ? ... : isLoading ? ... : null 的嵌套三元,违反项目风格约束。
    • 问题:三层状态不可读,后续扩展容易继续嵌套。
    • 已修复(803d5ccb0):抽出模块级 resolveMembershipQuoteErrorState({ isUpgrade, error, isLoading, onRetry }),用早返回表达三种状态。
  • 9. 方向键/Home/End 切换后焦点不跟随(maintainability · low,PlatformProfileRechargeModal/index.tsx:484-487)

    • 现状:键盘处理只 onChange(nextTab),没有把焦点移到新选中的 tab;档位网格同样问题。
    • 问题:roving tabindex 模式下键盘用户切换后焦点丢失,无障碍体验退化。
    • 已修复(cec4b7ec5):引入 tabRefs / productRefs,在 Arrow/Home/End 计算完 nextTab/nextProduct 后 onChange + focus()。
  • 10. 默认预选第 3 档缺说明(maintainability · low,PlatformProfileRechargeModal/index.tsx:1278-1279)

    • 现状:Math.min(2, pointProducts.length - 1) 直接写死“第 3 档”。
    • 问题:魔法下标没有业务解释,后人不敢改。
    • 已处理(9af662551):保留现有默认行为(充点商品同价,仅影响首屏选中项),抽出 defaultProductIndex 并补中文注释说明为何选中间档;没有改成 recommended,因为充点商品契约当前没有推荐标记,贸然改会变更首屏默认选中项。
  • 11. 下单入口携带无人消费的 kind(maintainability · low,apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts:435-438)

    • 现状:buyRechargeProduct(product: Pick<ProfileRechargeProduct, 'productId' | 'kind'>),调用处传 kind: 'membership',但函数体只读 product.productId。
    • 问题:后端通过 parse_runtime_profile_membership_product_id(productId) 推导商品类型,kind 是死代码,与注释“只需要把 productId 交给同一条下单链路”不符。
    • 已修复(da0ef16df):Pick 收窄为 'productId',移除 kind: 'membership'。
  • 12. 升级报价先做数值校验导致错误码不对(bug · medium,server-rs/crates/module-runtime/src/membership/upgrade.rs:164-165)

    • 现状:validate_runtime_profile_membership_upgrade_quote_input 先按 target.cycle_kind.cycle_count() 约束 cycle_index,之后才做领域检查 check_runtime_profile_membership_upgrade_allowed。
    • 问题:年付 → 月付且 cycle_index > 1 时,目标月付 cycle_count() == 1 先让数值校验失败,调用方拿到 InvalidQuoteInput 而不是语义正确的 CycleKindChangeUnsupported;现有测试只覆盖月→年方向(cycle_count() == 12)所以没暴露。
    • 已修复(1811b2206):先跑领域检查再做数值校验,并补年→月 CycleKindChangeUnsupported 测试。
  • 13. 档位校验未覆盖展示型权益(maintainability · low,membership/catalog.rs:162-167)

    • 现状:validate_runtime_profile_membership_plan_row_against_catalog 只校验 rank 唯一与月价/年价/泥点单调。
    • 问题:model_access 与 concurrent_job_limit 不在校验内,后台可存出高档位但权益更差的倒挂目录;代码又确实把它们当展示字段。
    • 已处理(78a43b6a1):这是“有意只做展示”的设计,按评审建议补文档注释,明确这两维是展示/判断字段、不做单调校验;没有新增校验以免误伤现有可编辑目录。
  • 14. membership-plans Tab 未登记导致非 owner 恒 403(bug · high,server-rs/crates/api-server/src/admin.rs:2241)

    • 现状:路由要求 Tab 权限 membership-plans,但 shared-contracts/src/admin.rs 的 ADMIN_TAB_PERMISSIONS 没有它。
    • 问题:管理员账号创建/更新会校验 Tab 白名单,未知值被拒,于是任何非 owner 管理员都拿不到该 Tab,新会员档位页实际变成 owner-only。
    • 已修复(c9ad084f7):ADMIN_TAB_PERMISSIONS 增加 membership-plans、长度 21 → 22;并给 every_assignable_admin_tab_has_a_backend_route 加断言,路由用到的每个 Tab 必须在白名单内,防止再次漏登记。
  • 15. 死错误变体 InvalidRechargeProductDuration(maintainability · low,server-rs/crates/module-runtime/src/commands.rs:421-424)

    • 现状:旧 duration_days == 0 校验被移除后,RuntimeProfileFieldError::InvalidRechargeProductDuration 已无构造点,但枚举和 Display 文案(“会员商品 duration_days 必须大于 0”)仍在。
    • 问题:错误枚举对外暴露一条不存在的校验路径。
    • 已修复(ef99e35d2):删除该变体及其 Display 分支,全仓确认无其他引用。
  • 16. RuntimeProfileMembershipTier 改名属破坏性 schema/线格式变更(bug · high,server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_type.rs:10-20)

    • 现状:生成绑定一度删除了 RuntimeProfileMembershipTier 及其 Month/Season/Year/Basic/Ultimate,快照改为 plan/cycle_index/cycle_count/cycle_kind。
    • 复核更新:你已提交 ba451bfc0,恢复 RuntimeProfileMembershipTier、旧 tier/cycle_period_days 与会员商品兼容列并重新生成绑定,schema guard 不再需要 allow-breaking,发布不再触发导出/清库/回灌。
    • 剩余风险:旧生成客户端仍不认识新增的 plan/cycle_kind 枚举值;且旧 tier → 新 plan 的数据回填仍然缺失(同 #5/#27)。
    • 建议:保留追加式兼容的同时补数据回填;旧客户端的解码窗口按实际灰度要求决定是否保留。
  • 17. updated_at_micros 由调用方写入(bug · medium,.../runtime_profile_membership_plan_admin_upsert_input_type.rs:22)— 经复核为非问题

    • 现状:模块 procedure admin_upsert_profile_membership_plan 接收带 updated_at_micros 的输入并原样落库。
    • 复核:该 procedure 由 require_editor_generation_runtime_service_identity 限定为服务身份,客户端无法直连;唯一调用方 api-server 的 admin_upsert_profile_membership_plan(runtime_profile.rs:1210)用 OffsetDateTime::now_utc() 现场生成该值,请求体里的同名字段不会被透传。
    • 结论:不存在“管理员客户端可写任意时间戳”的路径,未改代码。若后续把该模块函数直接暴露给客户端,需要重新评估。
  • 18. 升级报价快照用 String 而非枚举(maintainability · low,.../runtime_profile_membership_upgrade_quote_snapshot_type.rs:9-12)

    • 现状:RuntimeProfileMembershipUpgradeQuoteSnapshot 的 current_plan、target_plan、cycle_kind 原为 String(module-runtime/src/domain.rs:1894),而 RuntimeProfileMembershipPlan / RuntimeProfileMembershipCycleKind 枚举已存在。
    • 问题:类型层面允许非法值,解析/比较责任被推给消费方。
    • 已修复(5603185a7):三个字段改为对应枚举;模块构造点直接写入枚举,记录侧在 build_runtime_profile_membership_upgrade_quote_record 用 as_str() 转回对外 token(HTTP 契约不变);spacetime-client mapper 改用 back 映射还原枚举,并重新生成 module_bindings。
  • 19. 后台会员页硬编码并发哨兵 128(maintainability · medium,apps/admin-web/src/pages/AdminMembershipPlanPage.tsx:35-36)

    • 现状:页面自定义 UNLIMITED_CONCURRENT_JOB_LIMIT = 128,并在表单标签、校验文案、formatPlanLimits 三处比较;但后端 build_profile_membership_plan_response 已经下发派生的 unlimitedConcurrency。
    • 问题:后端哨兵一旦调整,后台展示会静默失真。
    • 已修复(44bbc846a):ProfileMembershipPlanAdminResponse 补 unlimitedConcurrency,列表展示改读该标志;ProfileMembershipPlanAdminListResponse 增加 unlimitedConcurrencyThreshold 并由 api-server 下发 MEMBERSHIP_UNLIMITED_CONCURRENCY,表单标签与校验文案改读它;本地假后台同步补齐两个字段。
  • 20. “本次到账”未体现首充赠送(bug · low,PlatformProfileRechargeModal/index.tsx:902-905)— 首充赠送已退役,非问题

    • 现状:结算弹窗 本次到账 只显示 product.pointsAmount,PlatformPointProductGrid 也只显示 pointsAmount;契约与后台仍保留 bonusPoints 字段。
    • 复核:首充赠送已退役(module-runtime/src/lib.rs:968「新方案取消首充赠送,六档充点一律无赠送、无角标」;commands.rs:417 对泥点商品 duration_days != 0 || bonus_points != 0 直接返回 InvalidRechargeProductFields),所以 bonusPoints 恒为 0,评审补丁反而会展示不存在的赠送。
    • 结论:展示 pointsAmount 是正确的,未改代码。遗留的 bonusPoints 字段(后台表单/契约/resolve_runtime_profile_points_recharge_delta)属可选清理,确认要退役可单独删。
  • 21. 钱包入口与充值弹窗测试断言过期(test · high,apps/ai-game-creator-shell/tests/appSurface/home.suite.ts:776-778)

    • 现状:PlatformMudPointWalletEntry 的余额按钮 aria-label 已改为 泥点账户:可用总额 …、充值按钮文案改为 会员与泥点;充值弹窗标题改为 会员与泥点、金额行改为 可用总额 … 泥点,并新增了“泥点充值”页签与“确认订单”二次确认弹窗。
    • 问题:旧断言 /^泥点 /、'充值'、'购买更多泥点'、'当前余额 …'、/60泥点.*购买/ 全部失配,测试在改动前就已经在 home.suite.ts:1013 失败。
    • 已修复(066d14636):钱包入口/充值按钮改用新可访问名;充值流程补上切换“泥点充值”页签、在“确认订单”弹窗内点击、以及新的余额文案断言。已跑 starts from the client home 与 opens the shared recharge modal 两条用例通过。
  • 22. 升级报价未校验到期日(maintainability · low,server-rs/crates/module-runtime/src/membership/upgrade.rs:152-153)

    • 现状:输入自检校验了周期窗口与 now,但没有校验 expires_at_micros。
    • 问题:允许 expires_at_micros < cycle_resets_at_micros(甚至已过期)仍拿到成功报价,报价结果内部自相矛盾;而该函数同时服务报价与下单路径。
    • 已修复(1d2574a43):加入 expiry_out_of_order = input.expires_at_micros < input.cycle_resets_at_micros 并拒绝为 InvalidQuoteInput,补对应测试。
  • 23. beijing_local_micros 用 0 兼作非法哨兵(maintainability · low,server-rs/crates/module-runtime/src/membership/cycle.rs:24)

    • 现状:函数在非法输入时返回 0,但北京 1970-01-01 08:00 本身就是合法的 0。
    • 问题:调用方无法区分“非法日期”和“合法 epoch”,advance_beijing_months_clamped 会把 0 当作锚点继续算。
    • 已修复(3db258d92):beijing_local_micros 改为返回 Option<i64>;advance_beijing_months_clamped 对夹取后的合法输入 expect;测试助手同步更新,并补“合法 epoch 0 可被区分”的用例。
  • 24. 会员业务拒绝被映射成 502(bug · medium,server-rs/crates/api-server/src/runtime_profile.rs:1511-1522)

    • 现状:is_runtime_profile_membership_domain_error 只匹配 会员升级被拒绝: 、会员档位 前缀和一组精确文案。
    • 问题:充值商品类型无效:会员商品已迁移到会员档位目录(后台仍 POST kind: "membership")与 会员商品 product_id 无法解析: …(存量待支付会员订单)这类可读业务拒绝落到 502 BAD_GATEWAY。
    • 已修复(3f9b28070):补上这两个前缀匹配,并新增 membership_business_rejections_map_to_bad_request 覆盖升级拒绝、商品 id 解析失败、旧会员商品类型三条路径;目录缺行仍按部署配置错误保持 502。
  • 25. 已有库不会补种会员档位目录(bug · medium,server-rs/crates/spacetime-module/src/runtime/active/profile.rs:8850-8853)

    • 现状:ensure_default_profile_membership_plan 只在 #[reducer(init)](仅建库时执行)与后台 upsert 时调用,读取路径(充值中心、下单快照、升级报价)不再懒播种。
    • 问题:对存量数据库 republish 后 profile_membership_plan 为空,充值中心无会员档位,下单/报价返回 会员目录缺少档位,直到管理员手动 upsert 一次才补齐。
    • 已修复(888a94ac8,你提交):membership_plan_row / membership_plan_records 恢复懒播种,下单校验、升级报价、充值中心读取目录前先补种代码内种子目录。
  • 26. 过期的新购结算会覆盖更新的会员(bug · medium,server-rs/crates/spacetime-module/src/runtime/active/profile.rs:9954-9960)

    • 现状:冻结的 Purchase 结算在下单时用户无有效会员,但结算时若 active_row 已存在,会直接走 apply_profile_membership_fresh_purchase(按 user_id 删除后重插)。
    • 问题:用户在订单创建与支付之间通过另一订单获得了更高级/更长会员时,这笔旧订单会静默覆盖新会员,可能降档并丢失已付时长;Upgrade 分支有 plan_before/cycle_kind_before 守卫,Purchase 分支没有。
    • 已修复(0fc564c4e):change_kind == Purchase 且 active_row.is_some() 时返回「会员状态已变化,请重新下单」;历史无快照的重算路径只会在 active_row 为空时产出 Purchase,不受影响,错误经既有分类器按 400 返回。
  • 28. 升级结算缺少周期身份快照(bug · medium,profile.rs:9931-9935)

    • 现状:冻结 Upgrade 结算只对比 plan_before/cycle_kind_before,ProfileMembershipOrderSnapshot 没有记录下单时的 cycle_index/cycle_started_at/cycle_resets_at。
    • 问题:若下单到支付之间会员周期已推进,apply_profile_membership_upgrade 会把按旧周期剩余比例算出的 grant_points 叠加到已重置的新周期 cycle_remaining_points 上,造成多发泥点。
    • 已修复(5826b2e56):按你说的“跟 Purchase 一样用快照”,ProfileMembershipOrderSnapshot / ProfileMembershipSettlement 增加 cycle_index_before 与 cycle_resets_at_before_micros,建单写入订单已有的会员快照列(不新增 schema 字段);支付前用纯函数 frozen_membership_cycle_matches 比对,账期已推进即拒绝并要求重新下单。历史无快照订单冻结值为 None,跳过校验。
  • 1. apps/admin-web/src/pages/pageUtils.ts:36-42 —— 「严格」整数解析实际接受小数/科学计数法/十六进制

    • 现状:parsePositiveInteger / parseNonNegativeInteger 先用 value.trim() === '' 判空,再用 Number(value) + Number.isSafeInteger。
    • 问题:Number 会把 '1.0'→1、'1e2'→100、'0x10'→16、' 42 '→42、'+5'→5 都变成安全整数,与「严格整数」注释不符;后台价格 / 点数 / 并发上限保存可被这类输入穿透。
    • 处理:已修复(658a39a35)。改为对 trim 后的字符串做 ^\d+$ 校验,只接受纯十进制数字;'+5' 现在也会被拒绝。
  • 2. apps/admin-web/src/pages/pageUtils.ts:53-61 —— 四个 helper 重复且两个宽松版行为完全等价

    • 现状:parsePositiveInteger('0') 返回 null,经 ?? 0 后与 parseNonNegativeIntegerOrZero('0') 都是 0;对任意输入两者结果相同。
    • 问题:重复实现,且名字暗示的「正 / 非负」差异在宽松版本里并不存在,容易误导后续调用者;改一处漏一处。
    • 处理:已修复(8ad490b39)。抽出内部 parseSafeIntegerText,四个导出函数改为薄包装;两个 OrZero 语义等价已写进注释。保留导出名以免改动调用点;是否真正合并成一个由你决定。
  • 8. packages/shared/src/components/PlatformProfileRechargeModal/index.tsx:739 —— 嵌套三元

    • 现状:按钮 children 为 disabled ? actionLabel : submitting ? '处理中' : '选择并确认'。
    • 问题:嵌套三元不易读,disabled / submitting 优先级不直观。
    • 处理:已修复(987e82605)。在 return 前用 if/else 计算 actionButtonLabel。
  • 9. packages/shared/src/components/PlatformProfileRechargeModal/index.tsx:519-527 —— 方向键索引逻辑重复 + 嵌套三元

    • 现状:页签 tablist 与同文件的 PlatformPointProductGrid 各写了一份完全相同的 Home/End/左右键循环索引逻辑,且都是嵌套三元(评审说的「在 PlatformPointProductGrid 里重复」实际是同一文件内的重复,不是跨文件)。
    • 问题:重复 + 难读,改一处漏一处。
    • 处理:已修复(93837a736)。抽出模块级 resolveRovingIndex(key, currentIndex, length)(switch 实现),两处共用;行为不变,组件测试通过。
  • 10. packages/shared/src/components/PlatformProfileRechargeModal/index.tsx:991-993 —— 升级报价未到时把标准价当「本次补付」

    • 现状:amountCents = quoteForTarget ? quote.amountCents : listPriceCents;升级确认页在报价加载中 / 失败时,大金额区显示新套餐标准价并标「本次补付」,CTA 也带这个价。
    • 问题:补差金额几乎总小于标准价,展示会严重误导用户。
    • 处理:已修复(951b44b58)。新增 amountPending = isUpgrade && !quoteForTarget:pending 时金额区显示 —,CTA 只显示「补差升级」并保持禁用(canSubmit 仍要求 quoteForTarget)。已补回归测试。注意:明细行里的「新套餐标准价」仍显示标准价,那是正确信息。
  • 16. packages/shared/src/components/PlatformProfileRechargeModal/index.tsx:1147-1151 —— 静态支付方式被标成可交互 radiogroup

    • 现状:支付方式用 role="radiogroup" 包一个不可交互的 role="radio" aria-checked="true" 的 span(恒为微信支付)。
    • 问题:读屏会播报一个无法更改的已选单选项,误导用户。
    • 处理:已修复(582a32946)。去掉 radiogroup / radio 语义,改为普通文本容器,样式不变。

脚本

  • 11. scripts/admin-web-fake-api.mjs:8 —— 硬编码「不限并发」哨兵无来源指引
    • 现状:UNLIMITED_CONCURRENCY_THRESHOLD = 128,注释只说与后端一致,没有路径。
    • 问题:后端常量改动后本地假数据会静默漂移,「不限并发」显示错误。
    • 处理:已修复(e855c52d7)。注释补上后端文件 server-rs/crates/module-runtime/src/membership/limits.rs 与常量名 MEMBERSHIP_UNLIMITED_CONCURRENCY。真正的单一来源需要生成脚本 / 共享常量(更大改动),未做。

后端(server-rs)

  • 6. server-rs/crates/module-runtime/src/membership/catalog.rs:151-154 —— 单调校验未声明「existing 必须完整」

    • 现状:validate_runtime_profile_membership_plan_row_against_catalog 只遍历 existing 里出现的行逐对比较,且从 crate 根 re-export。
    • 问题:若调用方传入被过滤 / 不完整的 existing,缺失档位造成的倒挂会静默通过,之后才在报价或目录读取时暴露。
    • 处理:已修复(b23ed023f)。补文档前置条件:existing 必须覆盖除 candidate 外的全部在售档位,并说明缺失会静默通过。未加运行时断言,避免影响现有调用方。
  • 7. server-rs/crates/module-runtime/src/membership/cycle.rs:62-63 —— index.max(1) 与报价侧的严格校验不一致

    • 现状:membership_cycle_window 用 index.max(1),index = 0 收敛为第 1 期;而 quote_runtime_profile_membership_upgrade 显式把 cycle_index == 0 判为 InvalidQuoteInput。已有测试固化了 0→第 1 期。
    • 问题:同一套 1-based 契约两处处理不一致,可能掩盖调用方的期号错误。
    • 处理:已修复(a16dbf7bc),选择「文档化」而非改行为:注明 0 被收敛为第 1 期是为兼容「期号水位未初始化」的存量行,与报价的严格拒绝不同。若你要统一成拒绝,需同步改测试与所有调用方。
  • 12. server-rs/crates/api-server/src/runtime_profile.rs:1831-1834 —— 微信虚拟支付未校验档位是否在售

    • 现状:会员档位只校验 center.membership_plans 里存在 item.plan == plan;该目录快照包含全部行(含下架与 Normal 占位)。
    • 问题:已下架档位或 Normal 占位仍能通过并进入微信虚拟支付签名流程。
    • 处理:已修复(4a2594a90)。判定改为 item.plan == plan && item.enabled && item.plan.is_purchasable(),与 resolve_enabled_profile_recharge_order_product 的可售口径一致(Normal 目录里 enabled=false 且 is_purchasable()=false)。
  • 13. server-rs/crates/module-runtime/src/civil_calendar.rs:39 —— saturating_add 静默产出错误日期

    • 现状:civil_from_days 用 date_key.saturating_add(719_468);极端日序号会饱和,返回格式合法但错误的年月日。
    • 问题:公共纯函数对越界输入静默给出错误结果。
    • 处理:已修复(b0b1e2519)。按建议文档化输入范围(约 ±7.9e11 天,即 i32 年份区间)与饱和行为,要求调用方保证范围。未改成 Option(会破坏公开签名与调用方),是否改成返回 Option 由你决定。
  • 17. server-rs/crates/module-runtime/src/membership/cycle.rs:36-39 —— 文档说越界返回 None,实现却饱和

    • 现状:beijing_local_micros 文档承诺「本地日期/时刻无法表示时返回 None」,但时间戳构造用 saturating_mul/add/sub;年份导致 i64 溢出时返回饱和后的 Some。
    • 问题:调用方拿到「看似合法」的损坏时间戳。
    • 处理:已修复(cc30d8ba7)。按评审补丁改为 checked_mul/checked_add/checked_sub?;新增测试断言 ±1_000_000 年返回 None。
  • 22. server-rs/crates/spacetime-module/src/runtime/active/profile.rs:8386-8388 —— 目录缺行回退 rank = 0

    • 现状:后台 upsert 档位时 resolve_runtime_profile_membership_plan_row(plan).map(|r| r.rank).unwrap_or(0)。
    • 问题:目录缺行会被写成 rank = 0,与 Normal 同档,掩盖未来枚举 / 目录不一致;module-runtime 的对应文档明确警告不要 unwrap_or(0)。
    • 处理:已修复(44feb7b0f)。缺行改为显式报错(会员目录缺少档位 X,无法解析升级比较基准)。

留给你处理的破坏性 / 高风险项

  • 3. server-rs/crates/shared-contracts/src/admin.rs:1253 —— granted_points_delta 有符号/无符号不一致(按你的决定:对齐 u64)

    • 现状:AdminMembershipOrderChangePayload.granted_points_delta: i64(admin.rs:1253),对应 RuntimeProfileMembershipOrderChangeSnapshot.granted_points_delta: i64(domain.rs:1753)与表列 profile_recharge_order.membership_granted_points_delta: i64(profile.rs:571);而报价侧 ProfileMembershipUpgradeQuoteResponse.granted_points_delta: u64(runtime.rs:706)、RuntimeProfileMembershipUpgradeQuoteRecord 也是 u64。
    • 问题:同一个「本期补点」量在不同层有符号 / 无符号不一致,对账需要 lossy cast,也容易漂移。
    • 处理:已修复(1744262b0)。表列 membership_granted_points_delta、module-runtime 快照、admin DTO 全链路改 u64,删除 as i64 与 u64::try_from 兜底;重生成绑定。SpacetimeDB schema 类型变更:本地若已有旧库,publish 时可能要求 --delete-data;你已确认没有线上数据、不需要迁移,所以未写回填。
  • 4. server-rs/crates/shared-contracts/src/runtime.rs:732-733 —— 封闭取值域用裸 String(按你的决定:全部枚举化)

    • 现状:AdminUpsertProfileMembershipPlanRequest.plan: String、model_access: String,注释明确本期不做服务端校验;其他新契约的 change_kind / cycle_kind 也是 String。
    • 问题:后台可写入任意 token(例如拼错 "ful"),前端 union 静默回退 fallback 文案,错误被持久化。
    • 处理:已修复(d7dec9b47;配套 token 枚举在 c660ca41f)。新增 ProfileMembershipPlanToken / ModelAccessToken / CycleKindToken / ChangeKindToken(serde(rename_all = "snake_case"),与 TS union 对齐),AdminUpsertProfileMembershipPlanRequest.plan/model_access 改为 token 枚举;非法 token 在反序列化阶段被拒(Axum 返回 4xx),后台不能再持久化任意字符串。ProfileMembershipUpgradeQuoteRequest.target_plan 也一并枚举化。注意:非法 token 的状态码由原来的 400 变为 Axum 的 422,前端只发合法 union 值,正常路径不受影响。
  • 5. server-rs/crates/module-runtime/src/domain.rs:1539 —— 缺一次性的 tier → plan 回填(按你的决定:不做,线上无数据)

    • 现状:ProfileMembership 保留退役列 tier: RuntimeProfileMembershipTier(profile.rs:444,运行时不读不写),新列 plan: RuntimeProfileMembershipPlan 带 #[default(Normal)](profile.rs 462-463)。全仓确认:没有任何 reducer / 迁移把 tier 映射到 plan。
    • 问题:若存在存量在册会员,发布后 plan 会取默认值 Normal 而 status 仍是 Active,等于丢失档位 / 权益 / 升级资格。
    • 处理:按你的决定「5 not online no need」跳过——当前没有线上数据,不需要回填;Month/Season/Year 的旧映射问题也随之作废。如果将来引入存量库,需要在这一步补一次性回填 + 幂等标记 + 迁移测试。
  • 14. server-rs/crates/module-runtime/src/domain.rs:1746-1750 —— 快照里的枚举字段被字符串化(按你的决定:全部枚举化)

    • 现状:RuntimeProfileMembershipOrderChangeSnapshot 的 change_kind: String、plan_before/after: Option<String>、cycle_kind_before/after: Option<String>;底层 profile_recharge_order 表列其实是 typed enum,mapper 用 as_str().to_string() 降级成小写 token。
    • 问题:字符串化把解析 / 校验推给每个消费者,与保留 typed enum 的 RuntimeProfileMembershipRecord 不一致;新增枚举变体若漏改 as_str() / parse() 会静默发错 token。
    • 处理:已修复(c660ca41f)。快照字段改为 RuntimeProfileMembershipChangeKind / Option<RuntimeProfileMembershipPlan> / Option<RuntimeProfileMembershipCycleKind>;RuntimeProfileMembershipUpgradeQuoteRecord 的 current_plan / target_plan / cycle_kind 同步枚举化;shared-contracts 的 admin payload 与 membership / plan / quote 响应改用 wire token 枚举,api-server / spacetime-client / spacetime-module 的 as_str().to_string() 全部去掉。JSON wire 值不变(仍是小写 token)。
  • 15. server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_order_change_snapshot_type.rs:10-14 —— 生成绑定重复了字符串契约(随第 14 项再生)

    • 现状:该文件由 SpacetimeDB 生成,字段是 String / Option<String>,与第 14 项同源。
    • 问题:生成绑定把枚举状态再编码成一套小写字符串契约,需要客户端维护并行解析。
    • 处理:已修复(c660ca41f)。在 module 源码 domain.rs 改完后用 npm run spacetime:generate 重新生成,绑定字段现在直接引用 RuntimeProfileMembershipChangeKind / Plan / CycleKind 枚举,未手改生成文件。spacetime:generate 会额外产生 ~18 个与本项无关的 *_and_return_procedure.rs 纯格式差异(rustfmt 版本漂移),已逐个 revert,只保留语义相关的绑定改动。
  • 18. server-rs/crates/api-server/src/runtime_profile.rs:1512-1525 —— 用中文错误字符串前缀决定 400 / 502(按你的决定:全部改成 typed error)

    • 现状:is_runtime_profile_membership_domain_error 靠 starts_with("会员升级被拒绝: ")、starts_with("会员档位") 等前缀加中文精确匹配列表,把模块错误映射成 400 / 502。
    • 问题:脆弱的字符串契约:任何新的会员领域拒绝若以裸 Err("...") 发出,就会被当成 502 基础设施错误,客户端看到基础设施失败而不是可读业务文案。模块侧已经有 RuntimeProfileMembershipUpgradeRejection / RuntimeProfileError 可集中承载。
    • 处理:已修复(68c8544a1)。新增 RuntimeProfileMembershipErrorCode(11 个拒绝码)与内部 RuntimeProfileMembershipDomainError { code, message };会员报价 / 充值中心(下单 / 确认支付)/ 后台档位 upsert 的 procedure 结果末尾追加 error_code: Option<...>,拒绝点显式赋码,中文 error_message 逐字不变(已用全量中文串比对确认 MESSAGES-IDENTICAL)。spacetime-client 新增 SpacetimeClientError::ProcedureRejected { code, message };api-server 删除会员中文前缀分类,改按错误码返回 400,普通 Procedure 字符串错误与基础设施错误仍 502(测试新增「同样的中文文案没有 error_code 时落 502」断言)。重生成绑定,只保留语义相关文件、revert 18 个纯格式文件。实际改动由子 agent 完成,我复核了 diff、逐字文案、错误码落点,并独立重跑全部检查。
    • 行为变化:会员目录缺少档位 ... 与「已迁移的会员商品」原先落 502,现在有明确错误码统一 400。兼容边界:procedure result 追加字段属 wire 契约变更,要求 module 与 api-server 同版本部署。兑换码链路本轮未改造,api-server 仍留一个兑换码文案匹配函数,已按你要求加 TODO(refactor): 兑换码链路尚未接入 typed error code(fdf636ce9)。
  • 19. server-rs/crates/spacetime-module/src/runtime/active/profile.rs:9421 —— advanced 把「初始化」和「真正换期」混为一谈(按你的决定:修复)

    • 现状:advance_profile_membership_cycle_to 里 advanced = row.cycle_resets_at.is_none() || row.cycle_index == 0;只要 advanced 为真就无条件把 cycle_granted_points / cycle_remaining_points 重写为 period_points,并触发 membership-period-reset ledger。
    • 问题:cycle_index == 0 是新列默认值,任何不是由新购买路径写入的行(例如 cycle_resets_at 已有但期号仍为 0、或旧数据)都会被强制 advanced = true;即使 at 还在第一期窗口内,也会静默重置本期余额,等于凭空产生一次换期。
    • 处理:已修复(168028d5e)。advanced = row.cycle_resets_at.is_none();cycle_index == 0 只补写水位 row.cycle_index = 1,不重置 cycle_granted/remaining_points,真正越过边界才重置;补测试覆盖「账期窗口已存在但期号水位为 0」时保留本期余额。
  • 20. server-rs/crates/spacetime-module/src/runtime/active/profile.rs:9331-9332 —— 「只读」procedure 会写库(按你的决定:选方案 a / 第 1 条)

    • 现状:membership_plan_row 读取前会 ensure_default_profile_membership_plan(ctx) 懒播种(表为空时整表插入);只读快照 / 报价路径也走这个 helper。而 init_profile_membership_plan_catalog 的文档写「只读入口不再调用 ensure... 写库」,build_profile_membership_snapshot_readonly 注释也写「不写库」。
    • 问题:注释与实现矛盾,调用方无法假定这些 procedure 无副作用;首次读取会写库。

先回答你的疑问:init 播种 是什么?它懒吗?

  • 「init 播种」= SpacetimeDB 的 #[reducer(init)] pub fn init_profile_membership_plan_catalog(...),它在数据库第一次被创建(首次 publish)时执行一次,把代码内目录灌进 profile_membership_plan 表。它不是懒的:那是发布时立即发生的 eager 播种。

  • 「懒播种」= ensure_default_profile_membership_plan(ctx) 被 membership_plan_row / membership_plan_records 这类读取 helper 调用,只有读的时候发现表为空才补写。它「懒」是因为实际写库推迟到第一次读取,而不是在 init 时完成。

  • 为什么要有懒播种:init reducer 只在数据库创建时跑一次,后续增量发布(给已存在的库加表 / 加字段)不会重新触发 init。所以「库里已有 profile_membership_plan 表但当时 init 没种过」这种存量库,如果只靠 init,目录会一直为空;懒播种是给这类库兜底。代价就是「只读」procedure 首次调用会写库,和注释矛盾。

  • 所以这条不是「改个注释」就能了事:ensure_default_profile_membership_plan 的懒播种注释和只读入口注释分别描述了两种设计意图,代码同时满足不了。需要你先决定播种策略,三条路选一:

    1. 保持懒播种,把「只读 procedure 首次会写库」写进文档(承认副作用);
    2. 去掉懒播种、只靠 init + 发布期 migration 播种(前提是发布流程会保证存量库也被种上);
    3. 只读投影不落表,直接在内存里回退到代码内目录(membership_plan_row 查不到时返回代码默认值,可读路径全部无副作用)。
  • 处理:已按第 1 条落地(9d7ca1b3b)——保留懒播种,把副作用写清楚:ensure_default_profile_membership_plan 注释说明它由 init 与读取 helper 共用、目录为空时首次读取会写库、表非空后幂等只读;init_profile_membership_plan_catalog 注释改为明确「不覆盖表已存在但为空的存量库」;报价与建单注释去掉「不写库」的绝对说法并注明懒播种例外;同步设计文档 §7/§10.2 与决策日志。行为不变(只改注释 / 文档)。

  • 21. server-rs/crates/spacetime-module/src/runtime/active/profile.rs:555-557 —— 旧迁移包缺新列会导入失败(按你的决定:不做迁移;先解释什么是「旧迁移包」)

    • 现状:ProfileRechargeOrder 新增 14 个 membership_* 快照列,ProfileMembership 新增 plan / cycle_index / cycle_count / cycle_kind;normalize_migration_row 对 profile_recharge_order 只回填 provider_transaction_id / expired_at / expiration_*,没有这些新列,也没有 profile_membership 分支。
    • 问题:row_from_json 用 serde_json::from_value 反序列化,#[default] 只作用于 SpacetimeDB 活 schema 迁移、不作用于 JSON 反序列化;recover_row_with_deleted_fields 只丢弃多余字段、不补缺失字段。因此导入旧迁移包时 profile_recharge_order(以及 profile_membership)会因 missing field 失败。

先回答你的疑问:旧迁移包 是什么?

  • 「迁移包」= 用 module 的 export_database_migration_to_file procedure 把整库数据导出的一个 JSON 文件(配套 import_database_migration_from_file / import_database_migration_incremental_from_file 及分块版本),用途是把一个 SpacetimeDB 库的数据搬到另一个库(换机器 / 换实例 / 本地灌数据)。文件里带 MIGRATION_SCHEMA_VERSION(当前 = 1,见 migration.rs),版本对不上会被 parse_migration_file 直接拒绝。
  • 「旧迁移包」= 在这次新增 membership_* / plan / cycle_* 列之前、用旧版 module 导出、但 schema_version 仍是 1 的 JSON。它的每行 JSON 里根本没有这些新列;导入时 serde_json::from_value 不会用 #[default] 补,于是 missing field 报错。也就是说:只要你还想用「旧库导出的包」灌进「新 schema 的库」,就必须在 normalize_migration_row 里补这些列的缺省回填。
    • 处理:按你的决定「21 no need migration」跳过——没有需要搬运的存量数据,就不补回填块。将来若要用旧导出包灌新库,再为 profile_recharge_order 和 profile_membership 补 or_insert(Null / 0 / 默认枚举),且 profile_membership.plan 的回填值要与第 5 项的回填结论一致。

附注:

  • 原文件末尾的 “LLM retry report summary / Context compaction” 是评审生成过程的元信息,不是代码问题,本次不再保留。
  • 复核中发现的过时 / 不准确结论已在正文标注(第 9 项重复范围、第 5 项遗漏 Month/Season/Year 映射、第 20 项建议与懒播种注释冲突)。
  • 第 18 项改动量较大(跨 module-runtime / spacetime-module / 生成绑定 / spacetime-client / api-server),实现由子 agent 完成,我复核了完整 diff、用全量中文串比对确认文案逐字未变、逐点核对错误码落点,并独立重跑了下方全部检查。
  • 本轮改动已跑:tsc guardrails、组件 eslint + prettier、vitest(PlatformProfileRechargeModal 9 项)、cargo check --workspace、cargo test -p api-server runtime_profile::tests(41 passed)、cargo test -p module-runtime --lib membership::(39 passed)、cargo test -p module-runtime -p api-server -p shared-contracts -p spacetime-module --no-run、npm run check:encoding(5258 files)、npm run check:spacetime-schema(92 tables)、npm run check:generated-bindings、cargo fmt --all -- --check、git diff --check。.env 与 .junie 是你工作区里既有的未跟踪 / 未提交改动,我没有碰、也不会提交。
  • cargo test -p api-server 有一个与本轮改动无关的既有失败:external_mcp::semantic::tests::semantic_catalog_adds_fifteen_tools_without_replacing_legacy_tools(断言 TOOLS.len() == 15)。本轮提交均未触碰 external_mcp,该失败在合并基线 dfa92607b 上即存在。

第 21 项追问:export_database_migration_to_file 是谁写的、为什么、本 PR 用了吗?

  • 作者 / 时间:kdletters,2026-04-27 14:54,提交 9a79494c6 feat: add spacetimedb json migration tooling。
  • 用途:整库 JSON 导出 / 导入的手工工具,配套 scripts/spacetime-export-migration-json.mjs / scripts/spacetime-import-migration-json.mjs / scripts/spacetime-migration-common.mjs,procedure 侧就是 migration.rs 的 export_database_migration_to_file / import_database_migration_*。package.json 与 docs/ 都没有引用它,属于按需手工执行、未接入 CI / npm scripts。
  • 本 PR 用了吗:没有。git log dfa92607b..HEAD -- server-rs/crates/spacetime-module/src/migration.rs 为空,本分支提交没有碰 migration.rs。
  • 那第 21 项为什么提它:第 21 项说的是「若用旧版导出的迁移包回灌新 schema 的库,会因新列缺回填而 missing field」,是假设将来要用该工具,不是本 PR 正在用。你已决定不做迁移,故不补。
  • 补充:会员重构本来在 21f69ef13(王德宇,2026-10-03 17:11)给 migration.rs 补过 profile_membership / profile_recharge_order 的旧包默认值,但 33 分钟后被 0c93256b8(王德宇,2026-10-03 17:44)整条 revert,revert 只写了 This reverts commit ...,没有记原因。所以当前基线是「有迁移工具、但没有会员列回填」的状态。

第 4 项追问:非法 token 从哪来?

  • 改动的 3 个 DTO 字段:AdminUpsertProfileMembershipPlanRequest.plan / .model_access(POST /admin/api/profile/membership-plans)、ProfileMembershipUpgradeQuoteRequest.target_plan(POST /api/profile/membership/upgrade-quote)。

  • 正常前端不会产生非法 token:AdminMembershipPlanPage 的档位 <select> 选项来自 membershipPlanLabels 固定映射,模型权限来自 modelAccessOptions(basic / full),TS 类型是 ProfileMembershipPlan / ProfileMembershipModelAccess union。

  • 会触发 422 的来源:绕过前端、直接向该接口发 JSON 的调用方——例如手写 curl / Postman / 脚本、联调时的旧客户端、或将来某个没复用 union 的新调用方,把 plan 写成 "proo" / "Plus" / "full_access" 之类。

  • 行为对比:改之前 plan 是 String,handler 里 parse_runtime_profile_membership_plan 解析失败返回 400;改之后 serde 在反序列化阶段拒绝,Axum Json 提取器返回 422(model_access 改之前甚至不校验、直接存任意字符串)。

  • 结论:422 只在「契约外的输入」出现,合法 token 一律不受影响;如果你希望这类非法 token 继续返回 400 而不是 422,我可以加自定义反序列化 / 提取器把 JsonRejection 归一成 400。

  • 1. apps/admin-web/src/app/adminRoutes.ts:57 —— 会员档位未编入桌面侧边栏分组

    • 现状:adminRoutes 已注册 membership-plans,后端 ADMIN_TAB_PERMISSIONS 也包含它;但 adminNavGroups 的 billing 分组里没有它。
    • 问题:桌面侧边栏由 getAdminNavGroups(routes) 按分组过滤生成,未编组的路由会被丢弃,桌面端看不到「会员档位」入口(只能走移动底栏或手输 hash)。
    • 修法:把 membership-plans 加入 billing 分组。
    • 结果:已修复并提交 48b688095。
  • 2. apps/admin-web/src/pages/AdminRechargeProductPage.tsx:133-135 —— 价格分/基础泥点未校验

    • 现状:handleSave 用 parsePositiveIntegerOrZero 解析,空串/非法输入落成 0;提交按钮只在 productId/title 为空时禁用,输入框即使 type="number" min="1",清空后原生校验也不拦截。
    • 问题:运营清空「价格分」或「基础泥点」会静默保存 0 价 / 0 泥点商品。
    • 修法:改用严格 parsePositiveInteger,解析为 null 时先提示表单错误并 return(在 confirmWrite 之前)。
    • 结果:已修复并提交 5a776f61c。
  • 3. apps/admin-web/src/pages/AdminRechargeProductPage.tsx:32-36 —— 默认 SKU 与定价比例硬编码

    • 现状:默认 SKU(points_60 / 600 分 / 60 泥点)与「¥1 = 10 泥点」同时写在顶部常量、页面 description 和注释里。
    • 问题:定价口径变更时多处容易漂移。
    • 修法(按你的意见:只删文案,不抽共享配置):删除页面 description 和注释里的「¥1 = 10 泥点」,并同步清掉公共充值弹层与测试里同口径的注释;默认 SKU 常量保留。
    • 结果:已修复并提交 985b8718b。
  • 4. apps/admin-web/src/pages/pageUtils.ts:66-68 —— 无调用方的重复 helper

    • 现状:parsePositiveIntegerOrZero 与 parseNonNegativeIntegerOrZero 对 0/非法输入行为一致。
    • 问题:parseNonNegativeIntegerOrZero 全仓无调用点,属死代码且与另一个语义重复;严格版 parseNonNegativeInteger 仍在 AdminProfileWalletConfigPage 使用。
    • 修法:删除 parseNonNegativeIntegerOrZero。
    • 结果:已修复并提交 6e419d0d8。
  • 5. apps/admin-web/src/pages/AdminMembershipPlanPage.tsx:397 —— 未知档位 code 渲染为空白

    • 现状:列表和「最近保存」直接 membershipPlanLabels[plan]。
    • 问题:后端返回未知 plan code 时得到 undefined,React 渲染成空白。
    • 修法:改用 formatMembershipPlan(未知值回退原 code);表单选项仍复用 membershipPlanLabels,保留导入。
    • 结果:已修复并提交 0a6c70a64。
  • 6. scripts/admin-web-fake-api.mjs:809-810 —— 假数据残留已退役字段

    • 现状:fake 充值商品对象仍返回 tier / membershipPeriodPoints / membershipPeriodDays / membershipQueueLimit / membershipDiscountBps。
    • 问题:这些字段已从 Rust 契约 ProfileRechargeProductConfigAdminResponse 与 adminApiTypes.ts 移除,假数据会掩盖前后端漂移。
    • 修法:删除这 5 个字段(bonusPoints / durationDays 仍是契约内退役列,保留为 0)。
    • 结果:已修复并提交 589a2623a。
  • 7. apps/admin-web/src/components/AdminUserDetailDialog.tsx:544 —— 会员状态直接渲染后端 token

    • 现状:plan / cycleKind 已本地化,但 status 仍是 normal / active 原样渲染。
    • 问题:把内部 token 暴露给运营人员。
    • 修法:membershipDisplay 新增 formatMembershipStatus(active→生效中、normal→非会员,未知原样返回),此处复用。
    • 结果:已修复并提交 ff2bd5b57。
    • 备注:同一 finding 还建议「非会员时收敛 cycle_index/cycle_count = 1/1 的账期展示」。我未改:后端注释明确保留过期行的周期信息用于展示「已失效套餐」,如何收敛需要单独定展示规则,属可选项。
  • 8. apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx:157 —— revision 未变时桶不同步

    • 现状:applyCatalog 只在 revision 变化(或非有限)时才 setModels / setUnavailableModels / setDefaultModelId;但下方回退 notice 用的是当次新取的 catalogUnavailableModels。
    • 问题:后端 revision 是目录级版本,而 models / unavailableModels 会按会员 access 过滤(见 api-server/src/llm/mod.rs::public_model_catalog)。会员升级后 revision 不变但两个桶会变,此时下拉仍是旧桶、notice 却按新数据算,界面与提示可能分叉。
    • 注意:apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx 有明确用例 keeps the applied catalog when the revision is unchanged,把「revision 不变就不更新界面」当作既定行为断言。评审建议的「每次成功都同步桶」会与该用例冲突。
    • 建议:改成按「revision + 两个桶内容」的指纹判断(同时修复档位变化与避免无变化重渲染),并同步更新该测试;或明确接受 revision 是唯一刷新锚点。留给你决定。
  • 9. packages/shared/src/components/PlatformProfileRechargeModal/index.tsx:395-397 —— 缺当前档位被误判为新客

    • 现状:!isActive || !membership || !currentPlan 统一返回「开通会员 / purchase」。
    • 问题:有效会员但目录里查不到当前档位(例如档位被下架/删除)时会被当成新客,对所有档位(含更低档与当前档)开放购买,可能提交重复/降档订单。后端目前返回全表(含下架行),所以是防御性边界。
    • 修法:!currentPlan 时返回禁用(套餐信息缺失),不再退化为新购。
    • 结果:已修复并提交 5d2de88a4。
  • 10. packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx:232 —— 「可用总额」与工具文档口径不一致

    • 现状:钱包入口、充值弹层等处把三池合计展示为「可用总额」;packages/shared/src/utils/mudPoints.ts 的文档却说 totalPoints「只用于展示,不代表『可用额度』」。
    • 问题:每日免费/月度/永久三池的重置与过期语义不同,把原始合计无条件叫「可用总额」可能误导用户。
    • 修法(按你的意见:钱包入口、充值弹层展示「可用总额」是对的):改工具文档,不再声明合计「不代表可用额度」;totalPoints 注释改为「前端统一以『可用总额』展示」,与各展示点一致。
    • 结果:已修复并提交 af419e82c。
  • 11. server-rs/crates/api-server/src/runtime_profile.rs:1481-1484 —— 遗留后台拒绝由 400 变 502

    • 现状:map_runtime_profile_client_error 只认 ProcedureRejected / Runtime,其余 SpacetimeClientError::Procedure(String) 一律 502。
    • 问题:bd06e4269 删除中文文案匹配后,admin_disable_profile_redeem_code 这类仍只带裸 error_message 的后台兑换码结果(如 兑换码不存在)从原来的 400 domain 拒绝变成 502。
    • 修法(按你的意见:业务拒绝必须带 typed code,而不是靠「裸字符串落 502」):module-runtime 新增 RuntimeProfileRedeemCodeAdminErrorCode(not_found / invalid_field / allowed_users_required),后台兑换码增改与列表 procedure 结果末尾追加 error_code;module 侧在拒绝点赋码,spacetime-client 后台 mapper 有码走 ProcedureRejected,api-server 由此返回 400。无码的裸字符串仍 fail-closed 到 502——这一条本身是对的(无法区分业务与基础设施),问题只在后台拒绝此前没有码。
    • 结果:已修复并提交 4f3faa04b(含重新生成 SpacetimeDB bindings、client/api-server 单测、设计文档 §5.8 与决策日志)。
  • 12. server-rs/crates/module-runtime/src/agc_model_access.rs:27-33 —— 未知 access 让整份目录解析失败

    • 现状:AgcModelAccess 只有 Basic / Full,AgcModel 是 deny_unknown_fields,access 用 #[serde(default)];只有缺字段才失败开放。
    • 问题:parse_catalog 把解析失败当「未初始化」,启动期会从上游重建目录,把人工标注的 Full 静默重置为 Basic;将来新增档位时滚动发布期间会抖动。
    • 修法:让未知取值也失败开放到 Basic。评审给的补丁不能编译——#[serde(other)] 必须放在最后一个变体。我按等价语义把 Full 提到前面、Basic 放最后并加 #[serde(other)],并补未知 access 回退 Basic 的单测。
    • 结果:已修复并提交 0c9a16ba1。
  • 13. server-rs/crates/module-runtime/src/membership/cycle.rs:28-34 —— 年份越界未先收口

    • 现状:beijing_local_micros 先用 days_in_month / days_from_civil 做历法换算,再靠 checked_* 兜底;年份没有范围检查。
    • 问题:days_from_civil 里 era * 146_097 是 i32 运算,|year| 超过约 590 万即溢出(debug panic、release 回绕成看似合法的值),违反「越界返回 None」契约。原测试只覆盖到 1_000_000,低于阈值。
    • 修法:进入历法换算前把 year 收口到 i64 微秒可表示范围(±292_278),并补 10_000_000 级用例。
    • 结果:已修复并提交 8bc48e9e6。
  • 14. server-rs/crates/shared-contracts/src/runtime.rs:437-438 —— 客户契约 status 仍是 String

    • 现状:ProfileMembershipResponse.status 是 String,而 plan / cycle_kind 已收紧为 token enum;ProfileMembershipStatusToken 已存在且 admin payload 在用,module-runtime 也已有 From<RuntimeProfileMembershipStatus>。
    • 问题:客户契约可表示任意/非法状态,且与 admin payload 类型分叉。
    • 修法(按你的意见:use enum):ProfileMembershipResponse.status 改为 ProfileMembershipStatusToken,BFF 用现有 From<RuntimeProfileMembershipStatus> 转换;wire 值不变(normal / active),前端 ProfileMembershipStatus 本就已是该 token 的别名。
    • 结果:已修复并提交 cc0c07223。
  • 15. server-rs/crates/shared-contracts/src/admin.rs:1727 —— access 注释措辞误导

    • 现状:AdminAgcModel.access 与 module-runtime AgcModel.access 的注释写成「该模型要求的会员档位」。
    • 问题:basic / full 是模型访问级别,不是会员 plan 档位(normal/starter/plus/pro/max),措辞会让运营误解。
    • 修法:注释改为「访问级别」。
    • 结果:已修复并提交 6d9125586。
  • 16. packages/shared/src/components/PlatformProfileRechargeModal/index.tsx:56-57 —— 未选中时 ArrowLeft 落点错误

    • 现状:resolveRovingIndex 注释明确 currentIndex 可为 -1,但 ArrowLeft 用 (currentIndex - 1 + length) % length。
    • 问题:-1 时得到 length - 2 而非 length - 1;泥点档位在默认选中 effect 跑之前确实有一帧 selectedProductId === null。
    • 修法:currentIndex < 0 时 ArrowLeft 返回 length - 1。
    • 结果:已修复并提交 f0dfddb91。
  • 17. packages/shared/src/components/PlatformProfileRechargeModal/index.tsx:909 —— 泥点档位禁用被会员下单波及

    • 现状:PlatformPointProductGrid 用 disabled={Boolean(submittingProductId)} 禁用全部泥点档位。
    • 问题:该 prop 现与会员下单共用,会员提交(membership-<plan>-<cycle>)也会禁用泥点档位,而泥点「充值」CTA 只看 selectedPointProduct 仍可点,出现跨页签耦合。
    • 修法:仅当提交 id 命中泥点商品时才禁用整组。
    • 结果:已修复并提交 d3f02d6bc。
  • 18. server-rs/crates/module-runtime/src/agc_model_access.rs:171-175 —— 遗留无档位 resolver 与新 resolver 并存

    • 现状:AgcModelCatalog::resolve / resolve_requested(只看 enabled + id)仍公开,生产调用方已迁到 *_for(access),但旧方法仍可被调用。
    • 问题:未来调用方可能走无档位校验的老路径,静默绕过会员检查。
    • 修法(按你的意见:remove them):删除 AgcModelCatalog::resolve / resolve_requested;validate 内联「默认项存在且启用」检查;相关单测改用 resolve_requested_for。
    • 结果:已修复并提交 7579213be。
  • 19. server-rs/crates/shared-contracts/src/llm_catalog.rs:147-149 —— protocol 默认值不感知 agent_mode

    • 现状:LlmModelSummary / LlmUnavailableModel.protocol 用 #[serde(default)],缺省落到 AgcModelProtocol::default() = openai_responses,与 agent_mode 无关;而 Cc 的默认协议实际是 anthropic。
    • 问题:遗留 payload agentMode: "cc" 且缺 protocol 会反序列化成不合法组合 Cc + OpenAiResponses,与 AgcAgentMode::default_protocol() 矛盾。
    • 修法(按你的意见:custom the default value):两个 DTO 改为经私有 wire 结构体反序列化,缺 protocol 时取 agent_mode.default_protocol();显式 protocol 优先,anthropic 别名仍映射 Cc。同步重新生成绑定注释。
    • 结果:已修复并提交 c7150b590。
  • 20. server-rs/crates/shared-contracts/src/llm_catalog.rs:24-25 —— 前向兼容策略不一致

    • 现状:同一响应里 LlmModelUnavailableReason 有 #[serde(other)] Unknown 兜底,而 AgcAgentMode / AgcModelProtocol 对未知字符串 fail closed;三者都在 GET /api/llm/models 同一响应,Rust 客户端整体反序列化。
    • 问题:后端将来新增 agent mode/协议时,旧客户端会整份响应解析失败(而非只影响该条目),与响应宣称的前向兼容目标不一致。
    • 修法(按你的意见):响应 DTO 的 wire 反序列化对未知 agentMode / protocol 回退到 Codex / agent_mode 的默认协议,旧客户端不再整份失败;enum 本身不新增变体,写入/输入校验仍走严格 parse,因此不影响请求侧门禁。
    • 结果:已修复并提交 4bd080eff。
- [x] **1. `civil_calendar.rs:57` `weekday_from_date_key` 存在 i64 溢出(bug · low)** - 现状实现:`(date_key + 3).rem_euclid(7) as u8 + 1`,先做 `date_key + 3`。 - 问题:该函数已 `pub` 并在 crate 根 re-export。`date_key` 接近 `i64::MAX` 时中间加法在 debug 构建 panic、release 构建静默回绕得到错误星期。现有调用方只传 2000..2100 的有界值,当前是潜在缺陷。审查意见属实。 - 建议修复:先取模再加偏移,避免中间加法。已按建议改为 `(date_key.rem_euclid(7) + 3).rem_euclid(7) as u8 + 1`。 - 提交:`603ecb088 修复(计费): 星期换算避免 i64 溢出` - [x] **2. `domain.rs:1263-1265` `RuntimeProfileMembershipPlan` 文档与实现不符(documentation · low)** - 现状实现:枚举只派生 `PartialEq`/`Eq`,没有 `PartialOrd`/`Ord`;真正的档位高低取自目录 `rank`(`runtime_profile_membership_plan_rank`)。 - 问题:文档却写「变体顺序即排序基准」,会误导后来者以为可以直接 `a < b` 或以为声明顺序是排序契约。审查意见属实。 - 建议修复:二选一——派生 `Ord` 并与 `rank` 对齐,或改写文档指向目录 `rank` 是唯一排序来源。选后者(非破坏性),并注明「当前声明顺序恰与 `rank` 一致,但顺序不构成契约」。 - 提交:`f81006465 文档(计费): 澄清档位排序以目录 rank 为准` - [x] **3. `lib.rs` 通配 re-export 摊平低层原语(maintainability · low)** - 现状实现(改前):`pub use civil_calendar::*;`(第 4 行)与 `pub use membership::*;`(第 19 行),连同 `application::*`、`domain::*` 等把大量符号摊到 crate 根(审查给的 `3-4` 行号不精确,`membership` 的通配在第 19 行)。 - 问题:低层公历原语(`civil_from_days`、`days_from_civil`、`weekday_from_date_key`…)和目录内部件都成了 `module_runtime` 公开 API 的一部分,模块继续增长后有命名冲突与「意外公开内部细节」的风险。审查方向合理。 - 已做(按你的要求:自己重构、不动其它 crate): - `civil_calendar` 去掉通配,只保留 `pub mod civil_calendar;` 命名空间;crate 内引用早已是 `crate::civil_calendar::{...}` 显式路径,无一行需要改。 - `membership` 改为策展式 `pub use membership::{...}`,只重导出真正对外的会员契约符号(`quote_runtime_profile_membership_upgrade`、`RuntimeProfileMembershipPlanSnapshot` / `RuntimeProfileMembershipPlanRecord`、`RuntimeProfileMembershipUpgradeQuoteInput`、`membership_cycle_window` / `membership_expires_at` / `beijing_local_micros`、目录与商品 id 解析、`MEMBERSHIP_UNLIMITED_CONCURRENCY` 等),其余留在 `membership::` 命名空间下。 - `spacetime-module` / `spacetime-client` / `api-server` 一行未改;`cargo check --workspace --all-targets` 通过,证明现有引用全部仍在导出白名单内。 - 未动 `llm_billing::*`、`agc_models::*`、`application::*` 等:它们本身就是 crate 的领域 / 应用契约,不属于本次「摊平低层原语」的问题面。 - 提交:`f09701f6d 重构(计费): module-runtime 去掉通配 re-export 改为策展导出` - [x] **4. `upgrade.rs:147` 报价函数不校验数值入参(bug · high,按审查描述)** - 现状实现(改前):`quote_runtime_profile_membership_upgrade` 只用 `check_..._allowed` 校验档位 / 周期,不做数值校验:`remaining_full_months = cycle_count.saturating_sub(cycle_index)`;`cycle_index` 允许 0 或大于 `cycle_count`;`cycle_count` 不校验是否等于 `target.cycle_kind.cycle_count()`;`now_micros` 不受限于本期窗口。 - 问题:`cycle_index = 0` 会让年付 `remaining_full_months = cycle_count`(多算一个月);`cycle_index > cycle_count` 被 `saturating_sub` 夹成 0(少收);`cycle_count` 与周期类型不一致会直接改价。 - **对审查结论的更正**:审查说这些值「可能来自客户端输入」并不准确。生产入口 `membership_upgrade_quote_input`(`spacetime-module` 内部)是从 `profile_membership` 行构造入参,`cycle_index` / `cycle_count` 取自库内 `max(1)` 后的行字段(写入端 `cycle_count = cycle_kind.cycle_count()`),并非 HTTP 直传;`RuntimeProfileMembershipUpgradeQuoteInput` 虽是 `pub` 且可 serde,但当前没有任何「把它当请求体反序列化」的入口。故现状是「内部调用方的不变量假设」,而非远程可利用缺陷。 - 已做:新增 `RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput`(wire token `invalid_quote_input`),并在报价函数开头加 `validate_runtime_profile_membership_upgrade_quote_input`:`cycle_index >= 1 && cycle_index <= cycle_count`、`cycle_started_at <= cycle_resets_at`、`now >= cycle_started_at`,命中即拒绝,不再被 `saturating_sub` 静默夹取。新增回归测试 `invalid_numeric_input_is_rejected`。该 rejection 目前只经 `as_str()` 对外,未波及其它 crate 的映射。 - **刻意未做的两项(保留给你,理由如下)**: - `cycle_count == target.cycle_kind.cycle_count()`:本轮已由第 12 项从根上解决——`cycle_count` 不再是入参,改由 `target.cycle_kind.cycle_count()` 推导,二者天然一致,无需再校验。 - `now <= cycle_resets_at`:现有契约刻意允许「已过期报价」按剩余比例 0 返回(测试 `remaining_ratio_is_zero_at_or_after_reset` 正是覆盖这条),强制上界会改行为,故未动。 - 提交:`83dbd0cc7 修复(计费): 升级报价拒绝越界数值入参` - 文档同步:`3eaae417b 文档(计费): 补充升级报价数值入参自检说明`(ADR §3 领域数据结构) - [x] **5. `upgrade.rs:154-155` `saturating_sub` 掩盖目录配置错误(maintainability · medium)** - 现状实现:`price_delta_cents` 与 `points_delta` 都用 `saturating_sub`;而 `check_runtime_profile_membership_upgrade_allowed` 只比较 `rank`,不校验「高 `rank` 是否真的更贵 / 更多点」。 - 问题:若某档位 `rank` 更高但价格更低或每期泥点更少(后台可改价格与每期泥点,`rank` 不可改),补差会被静默算成 0,而不是报错。审查意见属实。 - 建议修复:审查给两条路——(a) 运行时强制不变量(`price_delta == 0` / `points_delta == 0` 时返回错误并映射 4xx);(b) 把目录必须满足的单调不变量写进文档。 - 已做(非破坏性):在 `catalog.rs` 模块文档写明「按 `rank` 升序,月价 / 年价 / 每期泥点必须严格递增」,并新增回归测试 `enabled_plans_increase_in_price_and_points_with_rank` 固化种子目录不变量。 - 仍留给你:若想防御「后台把高 `rank` 档位改得更便宜」,方案 (a) 需新增 `RuntimeProfileMembershipUpgradeRejection` 变体并改报价 / 下单的 4xx 语义,属行为变更,未做。 - 提交:`8e1471d7a 测试(计费): 固化会员目录价格与泥点随 rank 递增的不变量` - [x] **6. `catalog.rs:140` 快照解析未校验可购买(maintainability · low)—— 核实为误报 / 过时** - 现状实现:`build_runtime_profile_membership_plan_snapshot` 已用 `enabled: row.enabled && row.plan.is_purchasable()` 把「可购买」编码进快照;`RuntimeProfileMembershipPlanSnapshot.enabled` 的文档已明确「`Normal` 与后台下架档位为 `false`」。下单路径 `resolve_enabled_profile_recharge_order_product` 还额外校验 `row.enabled && plan.is_purchasable()`。 - 问题:审查称「解析器不检查 `is_runtime_profile_membership_plan_purchasable`,直接调用就会给下架档位收费」,与现状不符——可购买性已被快照的 `enabled` 字段承载,且真实下单入口另有校验。不存在该现状缺陷。 - 结论:现有设计已覆盖风险,**无需修改**。 - 可选(留给你):若想要 defense-in-depth,可让解析器返回 `Result`。这属 API 变更,会改签名与调用方,由你决定。 - [x] **7. `catalog.rs:176` `runtime_profile_membership_plan_rank` 用 `unwrap_or(0)`(maintainability · low)—— 已按 (a) 落地** **先说 `rank` 到底是什么(回答你的 WTF)** - `rank` 是会员目录表 `profile_membership_plan` 里的一列整数,当前种子值:`Normal=0`、`Starter=1`、`Plus=2`、`Pro=3`、`Max=4`。 - 它唯一的作用是回答一个问题:「目标档位是不是比当前档位更高?」——判定在 `check_runtime_profile_membership_upgrade_allowed`:`target.rank > current.rank` 才算升级,否则返回 `NotUpgrade`。 - 为什么不直接比较枚举大小?因为旧实现用 `RuntimeProfileMembershipTier` 枚举的声明顺序当排名,历史上枚举顺序或别名一改就会悄悄改掉计费语义。现在把「高低」外置成数据(`rank`),枚举本身刻意**不**派生 `Ord`,避免有人再写 `a < b`;并且 `rank` 后台改不了(§5.6:`rank` 与 `plan` 不进后台 upsert 请求),只有代码里的目录种子能定,所以它是一份不会随运营配置漂移的稳定基准。 - 注意区分三个东西:`rank`(升级比较基准,代码内固定)、`sort_order`(目录展示排序,后台可改)、`enabled`(是否在售,后台可改)。 **问题** - 实现是 `resolve_runtime_profile_membership_plan_row(plan).map(|row| row.rank).unwrap_or(0)`。目录缺行时返回 `rank = 0`,而 `0` 正好等于 `Normal`,于是缺行会被当成「目标不高于当前」→ 误报 `NotUpgrade`,掩盖「目录缺行」这个真正的问题。 - 同样的写法在 `spacetime-module` 里也有一份内联拷贝:`upsert_profile_membership_plan_record`(`profile.rs:8150-8152`)写 `rank` 时是 `resolve_..._row(...).map(|row| row.rank).unwrap_or(0)`。 - 务实评估:目录是编译进代码的固定种子,覆盖全部枚举变体,`None` 分支实际不可达 → 属理论风险;但 `unwrap_or(0)` 把「缺失」编码成一个合法值,等于把编程错误伪装成业务结果,值得改掉。 **已做(方案 (a):把解析改成可失败)** - `runtime_profile_membership_plan_rank` 返回类型由 `u8` 改为 `Option<u8>`,实现简化为 `resolve_..._row(plan).map(|row| row.rank)`,缺行即 `None`。 - `plan_rank_follows_catalog_order` 测试断言同步改成 `Some(0)` / `Some(2)` / `Some(4)`。 - 全仓检索确认该函数此前只被自身测试与文档链接引用(`lib.rs` 仅是 re-export、`domain.rs` 是文档链接),故改签名不改任何生产行为。 - 按你「不动其它 crate」的要求,`spacetime-module` 里的内联拷贝(`profile.rs:8150`)本轮未动;如果你也想把它改成显式断言 / 显式错误,那是跨 crate 改动,需要你放行。 - 文档同步:ADR §3 的访问器签名改为 `-> Option<u8>` 并说明缺行返回 `None`。 - 提交:`047e6d72e 重构(计费): 会员档位 rank 解析改为可失败返回` - 文档提交:`51c5784c4 文档(计费): 同步会员档位 rank 可失败返回说明` - [x] **8. `cycle.rs:25-27` `beijing_local_micros` 文档返回说明错误(documentation · low)** - 现状实现:日期非法时 `return 0`(Unix epoch)。 - 问题:文档写「本地日期不存在则返回原值」,与实现相反,会误导调用方以为拿到原值且无错误信号。审查意见属实。 - 建议修复:改文档说明返回 `0`,或改签名为 `Option<i64>`/`Result`。选改文档(非破坏性)。 - 提交:`5ab6f161d 文档(计费): 修正北京本地时间换算的返回值说明` - [x] **9. `civil_calendar.rs:39` `civil_from_days` 存在 i64 溢出(bug · medium)** - 现状实现:`let z = date_key + 719_468;`。 - 问题:函数已 `pub` 并 re-export。传入接近 `i64::MAX` 的 `date_key` 时 debug panic、release 回绕出错误日期。审查意见属实。 - 建议修复:改 `saturating_add`。 - 提交:`8b8213f0c 修复(计费): 公历反算避免 i64 溢出` - [x] **10. `catalog.rs:64` 每次解析都重建目录(performance · low)—— 作者拍板 wont fix,仅加 TODO 注释** - 现状实现:`runtime_profile_membership_plan_catalog()` 每次 `vec![...]`,每行 `String` 都是新分配;`resolve_..._row` / `..._rank` / `..._purchasable` 都经由它,单次报价会重建数次。 - 问题:纯种子数据却每次分配,属可优化项;但只有 5 行,实际开销可忽略。 - 建议修复:用 `std::sync::LazyLock<Vec<...>>`(或 `OnceLock`)缓存一次;若要彻底零拷贝,可把返回值改为 `&'static [...]`。 - 处理(按你要求 leave):不改成 `LazyLock` / `&'static`,只在函数体首行加 `// TODO(membership-catalog): …` 注释,记录「只有 5 行、开销可忽略、按作者意见暂不修复」。 - 提交:`bc3d8dc7d 注释(计费): 记录会员目录重建暂不缓存的原因` - [x] **11. `cycle.rs:24` `time_of_day_micros` 未做范围校验(bug · low)** - 现状实现:只校验年月日,`time_of_day_micros` 直接进入 `days_from_civil(...) * DAY + time_of_day − offset`。 - 问题:传入 `>= PROFILE_RUNTIME_DAY_MICROS` 或负值会被顺延 / 回退到相邻日期,而不是按契约拒绝。虽是 `pub` 原语,当前调用方都传 `[0, DAY_MICROS)`。审查意见属实。 - 建议修复:增加 `[0, PROFILE_RUNTIME_DAY_MICROS)` 校验,越界返回 0。已按此实现,并同步把文档改为「日期或当日时刻越界都返回 0」。 - 提交:`58a79a2e4 修复(计费): 北京本地时间换算校验当日时刻范围` - [x] **12. `upgrade.rs:36-37` `cycle_count` 取调用方原值、未与周期类型对齐(bug · medium)—— 已按「总期数不由入参提供」落地** - [x] 1. `.prettierrc.json` 里针对 `packages/shared/src/contracts/generated/**/*.ts` 的覆盖配置已是死配置(该目录已被 `.prettierignore` 忽略)。已删除该 `overrides`。提交 `f631a2f06`。 - [x] 3. 充值商品页不再区分 `entry.kind`,历史会员商品行会被误渲染成「0 + 0 泥点」且保存必被拒。已在 `refreshProducts` 里过滤出 `kind === 'points'`,会员档位统一到「会员档位」页维护。提交 `5fabe6f2f`。 - [x] 4. `membershipPlanResult` 未随登出 / 换管理员登录清空,会显示上一位管理员「最近保存」面板。已在 `clearSession` 与 `handleLogin` 两处补 `setMembershipPlanResult(null)`。提交 `d49c52735`。 - [x] 5. 会员档位下拉选中目录里不存在的档位时,表单只更新 `plan`,其余字段沿用上一档。已新增 `clearForm`,无匹配行时标题/价格/每期泥点/模型权限/并发/上下架/排序全部复位并清空 `rank`。提交 `848f0b54c`。 - [x] 7. 钱包 store 已移除 `applyLegacyWalletBalanceSnapshot`,但 `apps/ai-game-creator-shell/tests/walletStore.test.ts` 仍断言它,CI 必挂。已更新断言键列表。提交 `ac5a03bf7`(`npx vitest run apps/ai-game-creator-shell/tests/walletStore.test.ts` 14/14 通过)。 - [x] 8. `packages/shared/src/utils/mudPoints.ts` 的 `resolveProfileMudPointBalanceView` 未兜底 `dailyFreeResetPoints`,后端缺字段时界面显示「每天重置为 NaN 泥点」。已补 `?? 0`。提交 `40e4e6e38`。 - [x] 9. 同上文件固定 `Asia/Shanghai` 格式化时 `hour12: false` 在部分引擎会落到 h24,午夜渲染成 `24:00`。已改为 `hourCycle: 'h23'`。提交 `40e4e6e38`。 - [x] 16. `module-runtime/src/membership/upgrade.rs:161-162` 用 `saturating_sub` 把倒挂目录静默算成 0 元升级 / 0 泥点。已改为 `checked_sub(...).ok_or(InvalidQuoteInput)?`,并新增单测 `inverted_catalog_price_and_points_are_rejected`。提交 `92917f57c`。 - [x] 17. `AdminMembershipPlanPage.tsx` 月价/年价输入 `min={1}` 会让按设计价格为 0 的 `normal` 占位档被原生校验挡住。已改为 `min={isPurchasable ? 1 : 0}`。提交 `848f0b54c`。 - [x] 18. `module-runtime/src/commands.rs` 非可购的 `normal` 占位档原样透传 `model_access` / `concurrent_job_limit`,与文档注释不符。已改为固定取目录种子基线(`resolve_runtime_profile_membership_plan_row`)并同步注释(`commands.rs:468-485`)。⚠ 这是**行为收紧**:走后台改「非会员基线」的入口现在无效,请你确认是否认可;不认可可回退提交 `119eb7c8b` 中这段。提交 `119eb7c8b`。 - [x] 19. `InvalidRechargeProductTier` 文案「会员商品 tier 无效」误导(现在用于两种与 tier 无关的情况)。已拆成 `InvalidRechargeProductKind`(「充值商品类型无效:会员商品已迁移到会员档位目录」)与 `InvalidRechargeProductFields`(「泥点商品不支持 duration_days 与 bonus_points 字段」)。提交 `119eb7c8b`。 - [x] 20. `module-runtime/src/membership/cycle.rs:44` 的 `as i32` 截断 — **复核结论:误报,无需改代码**。`year` 来自 `beijing_local_date_parts`(`i32`),`months: u32`,可达到的 `total_months.div_euclid(12)` 上界约 6.5e8,远小于 `i32::MAX`,要截断需要 `i32` 无法表达的年份。review 给的 `clamp` 补丁也解决不了它声称的「窗口越界」——真要防应校验 `index` 上界。 - [x] 21. 默认充点档迁移不认得上一版迁移留下的行(停用行 `bonus_points` 被保留成面额、180/300/680 变成「首充加赠」行)。已放宽停用行判定并新增「首充加赠」分支,同时把测试改成真实迁移后形态。提交 `6d04f88f4`。 - [x] 10. `server-rs/crates/spacetime-module/src/runtime/active/profile.rs` 管理接口不校验调用方身份(security / high)。修复前:`admin_list_profile_membership_plans`、`admin_upsert_profile_membership_plan`、`get_profile_membership_upgrade_quote` 等配置管理 procedure 只做入参字符串校验,完全信任入参里的 `admin_user_id` / `user_id`,不像 `mark_profile_recharge_order_paid_and_return` 那样先 `require_editor_generation_runtime_service_identity(tx, caller)`;除 API server 外若有客户端能直连模块,就能改会员价格目录(现在价格的唯一来源)或查任意用户报价。已修:10 个配置管理 procedure 全部补上模块级 caller 校验(`let caller = ctx.sender(); ctx.try_with_tx(|tx| require_editor_generation_runtime_service_identity(tx, caller)? /* 原逻辑 */)`,写法与 `settle_llm_router_quota_and_return` 一致),现在只有持有 `editor_generation_pricing_config.writer_identity` 的 api-server 服务身份可调用。提交 `e5de57f7f`。 - [x] 11. `profile.rs` 结算不比对订单金额(high)。修复前:`apply_profile_membership_purchase` 按「当时目录」重算报价并授予权益,`quote.amount_cents` 从不与订单金额比较;订单 Pending 期间改价 / 改每期泥点,会出现「按旧价扣款、按新价给权益」或反向不一致。已修:采用本项建议里的「建单时持久化报价并在结算复用」——`create_profile_recharge_order_record` 在建单时把档位 / 周期 / 每期泥点 / 补点 / 金额 / 价格明细冻结进订单的会员快照列,`apply_profile_recharge_purchase` 结算只认这份快照、不再回查目录,`enabled` 检查也一并移除,因此下单后改价 / 下架都不影响已受理订单;历史 Pending 无快照订单仍回退按目录重算旧口径。提交 `e5de57f7f`。 - [x] 13. 建单金额用了过期账期(medium)。修复前:`resolve_profile_membership_order_amount_cents` 直接读库里 `profile_membership` 行,不像报价 `build_profile_membership_upgrade_quote_record` 与购买 `apply_profile_membership_purchase` 那样先刷新账期。问题:会员周期是懒刷新,用户跨过结算边界却没触发刷新时,库里的 `cycle_resets_at` 已是过去时间、`cycle_remaining_points` 还是上一期旧值;年付补差要按「本期还剩几个完整月」折算,用过期的账期数据算就会算错,建单金额与预览、结算三处不一致。已修:新增只读投影 `project_profile_membership_cycle_at`(`profile.rs:9250`),用与刷新路径完全相同的推进数学 `advance_profile_membership_cycle_to` 在内存里把会员行投射到建单时刻,再据此算补差,全程不写库。为什么不是「更新 cycle_index」:`cycle_index` 是账期窗口与账本元数据的持久锚点,必须落库;建单只需要「假如现在刷新会得到什么」的投影值,没必要真的推进已存储的索引——所以改成只读计算,而不是把「建单」变成会推进账期的写操作。提交 `e5de57f7f`。 - [x] 14. `!target.enabled` 让 Pending 订单无法支付(medium)。修复前:`resolve_profile_recharge_order_product` 为「按建单时档位结算」特意跳过 `enabled` 检查,但 `apply_profile_membership_purchase` 里 `if !target.enabled { return Err(...) }` 又直接拒绝;管理员在订单 Pending 时下架该档位,支付回调就会失败,订单永远付不掉。已修:按你选的 (c) 方案——建单时把「档位 + 价格」快照写进订单,结算只认订单快照、完全不看目录 `enabled`,既不会卡死已付款订单,也便于按订单审计。提交 `e5de57f7f`。 - [x] 22. 后台 upsert 不保证 rank→价 / 点单调(medium)。修复前:写入侧只拒 0 值,不校验跨档排序不变量;一旦后台把高档位定得比低档位便宜 / 每期泥点更少就能落库,报价侧虽会报错(`92917f57c`),但目录本身已被污染。已修:新增 `validate_runtime_profile_membership_plan_row_against_catalog`(`module-runtime/src/membership/catalog.rs`),在 `upsert_profile_membership_plan_record` 写库前(`profile.rs:8286`)把本次这一行与目录其余每一行比较,要求 `rank` 严格递增且月价 / 年价 / 每期泥点随 rank 严格递增,违反返回 `InvalidMembershipPlanOrder`;因为只看「本次这一行 vs 其余行」,历史脏目录仍能逐档改回。提交 `e5de57f7f`。 - [x] 12. `profile.rs:8169` 报价 procedure 会写状态(medium)。修复前:`get_profile_membership_upgrade_quote` 注释写「只读」,但 `build_profile_membership_upgrade_quote_record` 在 `profile.rs:8182` 调用 `refresh_profile_wallet_expiring_points`,会过期会员、推进账期、改钱包流水;单纯预览报价就改会员 / 钱包状态,还可能提前消耗用户的懒刷新。已修:去掉该写库刷新,改用 `e5de57f7f` 新增的只读投射 `project_profile_membership_cycle_at`(`profile.rs:9265`)在内存里把账期推进到当前时刻再报价,全程不写库、不发账本,仍与建单 / 结算同口径,报价接口名副其实只读。提交 `90e520fdb`。 - [x] 3. `PlatformProfileRechargeModal/index.tsx:1436` —— 硬编码「¥1 = 10 永久泥点」。 现状:充点档位完全由后台 `profile_recharge_product_config` 配置,但弹窗底部这句汇率文案写死在组件里,是本页唯一不跟随目录的数据。 问题:后台改价或改点数后文案与实际商品不一致,误导用户。 修复:按你的决定直接删掉这句硬编码文案,不做契约变更。提交 `c165a032c`(`fix(充点弹窗): 删除硬编码的充值汇率文案`)。 - [x] 4. `membershipCopy.ts:12-15` —— 客户端重复定义后端事实。 现状:`RECOMMENDED_MEMBERSHIP_PLAN = 'plus'` 与 `UNLIMITED_CONCURRENT_JOB_LIMIT = 128` 都写在客户端;`128` 需手工与后端 `MEMBERSHIP_UNLIMITED_CONCURRENCY` 对齐,`'plus'` 是纯运营推荐、后端目录没有对应字段。 问题:后台调整档位或并发上限后 UI 会静默失真。 修复:在 HTTP 层 `ProfileMembershipPlanResponse` 增加由后端派生的 `recommended` 与 `unlimitedConcurrency` 两个布尔(`recommended` 取自 `PROFILE_MEMBERSHIP_RECOMMENDED_PLAN`,`unlimited_concurrency` 由 `is_unlimited_concurrency` 判定);前端 `ProfileMembershipPlanRecord` 契约补上这两个字段,`membershipCopy.ts` 删除 `RECOMMENDED_MEMBERSHIP_PLAN` / `UNLIMITED_CONCURRENT_JOB_LIMIT` / `isUnlimitedConcurrency`,并发文案改读 `plan.unlimitedConcurrency`,充值弹窗推荐标签改读 `plan.recommended`。说明:为避免 procedure 层绑定大改,只在 HTTP DTO 上暴露标志,procedure 级 `RuntimeProfileMembershipPlanRecord` 未动;后台 DTO 现在仍有本地 `128` 常量,属残留重复,未在本项扩围。提交 `4c6af9f19`。 - [x] 6. `module-runtime/membership/upgrade.rs:136-139` —— 报价校验放过已过账期。 现状:`validate_runtime_profile_membership_upgrade_quote_input` 原先只拒绝 `now < cycle_started_at`,允许 `now >= cycle_resets_at`;此时剩余比例为 0,但 `remaining_full_months = cycle_count − cycle_index` 仍按调用方传入的旧 `cycle_index` 计算,年付会多收整月。 问题:历史待支付订单重算路径把库里的旧行直接喂进报价函数、不投射账期,会触发「未推进的 cycle_index + 已过期窗口」。 修复:按你的决定采纳评审建议,把 `now >= cycle_resets_at` 也判为非法报价入参,报价函数直接拒绝已过账期窗口。提交 `4621f76a5`。 - [x] 21. `spacetime-module/profile.rs:9875-9878` —— 升级订单跨到期被少收。 现状:订单创建时按补差冻结 `amount_cents`;支付确认时若会员已到期,`active_row` 为 `None`,代码走 `apply_profile_membership_fresh_purchase`,按冻结档位重新起算并发放整期泥点。 问题:用户可在有效期内用很小的补差价建单,等会员过期后再支付,等于用零头买一个完整周期。 修复:在 `apply_profile_membership_purchase` 的 Upgrade 分支、`active_row` 为 `None` 时,用当前目录价做校验:`check_expired_membership_upgrade_settlement`,补差金额不足以覆盖新购价时拒绝支付并提示「会员已到期,请按新购价格重新下单」。提交 `f9b0cf786`。 - [x] 22. `spacetime-module/profile.rs:455-456` —— `tier → plan` 无迁移。 现状:schema 删掉 `tier` / `cycle_period_days`,新增 `plan`(默认 `Normal`)与 `cycle_kind`(默认 `Monthly`),没有旧档位映射。 问题:若库中存在历史会员行,会被读成 `Normal`,续期时静默丢权益。 处理:按你的决定「会员侧未上线、无存量行,可安全删除」,本次**不做回填迁移**。已确认代码里只剩注释提到旧 `tier` / `cycle_period_days`(`domain.rs:1234`、`profile.rs:454`),没有现役字段或调用方。发布前仍需用一个真实库确认 `profile_membership` 为空;若发现存量行再评估迁移。 - [x] 23. `spacetime-module/profile.rs:9147-9148` —— 只读路径写库。 现状:`membership_plan_row` / `membership_plan_records` 原先会调用 `ensure_default_profile_membership_plan`,在表为空时插入种子行,而它们同时服务于标注「只读」的升级报价 procedure 与充值中心快照。 问题:报价请求会写库并初始化目录,与只读契约不符。 修复:按你的决定把目录种子显式挪到模块初始化生命周期:新增 `#[spacetimedb::reducer(init)] init_profile_membership_plan_catalog`,只在数据库首次创建时整表播种;`membership_plan_row` / `membership_plan_records` 去掉懒播种,`ensure_default_profile_recharge_product_config` 也不再顺带初始化会员目录。存量数据库此前已由懒播种写入目录行,新建数据库由 init 播种。提交 `6f0d773dd`。 - [x] 25. `api-server/admin_recharge.rs:163-168` —— GET 用户详情会改用户钱包。 现状:`admin_get_user_detail`(GET)内部复用 `get_profile_recharge_center`,其快照构建会调 `refresh_profile_wallet_expiring_points`,发放每日免费泥点、推进 / 重置会员账期、写钱包余额与账本。 问题:后台只是打开用户详情就可能修改该用户的钱包与会员状态并插入账本行,审计上说不清是谁改的。 修复:新增纯读 procedure `get_profile_membership_snapshot_and_return`(不刷新钱包 / 账期、不写库,仍校验 service identity),在 `module-runtime` 增加 `RuntimeProfileMembershipGetInput` / `RuntimeProfileMembershipSnapshotProcedureResult` 与入参构造器,`spacetime-client` 增加 `get_profile_membership_snapshot` facade、`From` 转换与结果映射并同步生成绑定;`build_profile_membership_snapshot` 拆成「写库路径(先 refresh)」与「纯读路径(用 `project_profile_membership_cycle_at` 只读投影)」两条,共用 `from_row` 投影;`admin_get_user_detail` 改用只读快照。提交 `43f0c5112`。 - [x] 27. `spacetime-module/profile.rs:9408-9411` —— 升级金额使用客户端时间。 现状:建单的补差金额用 `created_at`,来自 `create_profile_recharge_order_and_return` 的入参 `created_at_micros`(经 HTTP 层客户端可传)。 问题:客户端可把 `created_at_micros` 设到 `expires_at` 前一刻,把剩余比例压到 0、金额压到接近 0,再按该金额支付并在结算时拿到目标档位整期泥点。 修复:按你的决定彻底移除客户端时间参数(`forbid`):`RuntimeProfileRechargeOrderCreateInput` 删除 `created_at_micros`,`build_runtime_profile_recharge_order_create_input(user_id, product_id, payment_channel)` 同步收窄;module 侧改用 `ctx.timestamp` 作为 `created_at_micros` / `order_id`;HTTP 层删除 `current_unix_micros()` 与调用参数;`spacetime-client` facade、mapper、生成绑定同步。提交 `3e2ab3cf7`。 - [x] 28. `spacetime-module/profile.rs:9864-9874` —— 待支付升级订单未校验冻结前状态。 现状:结算时对当前行直接 `apply_profile_membership_upgrade`,不再比对 `settlement.plan_before` / `cycle_kind_before`。 问题:若用户先付了更高的升级单,或会员到期后按不同周期重新购买,旧订单落地会无条件覆盖 `plan` / `cycle_kind` / `cycle_count`,却保留当前 `expires_at` / `started_at`,可能把 Max 降回 Pro,或产生 `Yearly` + 一个月到期的矛盾行。 修复:按你的决定,在应用升级结算前校验 `row.plan == settlement.plan_before` 且 `row.cycle_kind == settlement.cycle_kind_before`,不等则拒绝并提示「会员状态已变化,请重新下单」;`api-server` 把该文案纳入会员域错误(400)映射。提交 `3031f5887`。 --- ## 其余自动修复项(此前已提交) - [x] 1. `adminApiTypes.ts:978` —— 订单变更类型收窄。**已核实** 现状:`changeKind: 'purchase' | 'upgrade' | string`。问题:`| string` 让字面量联合退化成 `string`,拼写错误或未来新增的 `downgrade` 会静默流入展示与分支;后端 `RuntimeProfileMembershipChangeKind` 只会输出 `purchase` / `upgrade`。 修复:删掉 `| string`,非法取值改由编译期拦截。提交 `cd0cbacf1`。 - [x] 2. `adminApiTypes.ts:1056-1057` —— 会员状态收窄。**已核实** 现状:`AdminProfileMembershipPayload.status: string`。问题:后端 `RuntimeProfileMembershipStatus` 只有 `normal` / `active`,`string` 让未知状态直接进入 `MembershipSection` 展示。 修复:收窄为 `'normal' | 'active'` 并补注释。提交 `7dd26b9cd`。 - [x] 5. `membershipCopy.ts:87-89` —— `isUnlimitedConcurrency` 无人调用。**已核实** 现状:该函数已导出但全仓没有调用方,两个并发文案函数各自内联 `>= UNLIMITED_CONCURRENT_JOB_LIMIT`。问题:哨兵判定存在三份实现,改一处容易漏另一处。 修复:两个文案函数改为委托 `isUnlimitedConcurrency`,判定只留一份。提交 `cf6391962`。(第 4 项后该函数已随硬编码常量一并删除。) - [x] 7. `AdminMembershipPlanPage.tsx:442-453` —— `parseInt` 静默截断。**已核实** 现状:`parseInteger` 用 `Number.parseInt(value, 10)`。问题:`type="number"` 里合法的 `"1e2"` 会解析成 `1`、`"1.9"` 变成 `1`,价格 / 泥点 / 并发会被静默存错。 修复:两个解析函数都改为 `Number(value)` + `Number.isSafeInteger`。提交 `bfd96a7c2`。 - [x] 8. `AdminMembershipPlanPage.tsx:114-117` —— 每期泥点缺少提交前校验。**已核实** 现状:只有月价 / 年价 / 并发有友好校验,`periodPoints` 把空值变成 0 后直接提交。问题:后端 `validate_runtime_profile_membership_plan_row_against_catalog` 以 `InvalidMembershipPlan` 拒绝,管理员要点完确认弹窗才收到笼统目录错误。 修复:非 `normal` 档位新增「每期泥点必须是大于 0 的整数」,解析结果复用到请求体。提交 `58e07ba28`。 - [x] 9. `useAccountWallet.ts:395-398` —— 报价请求竞态。**已核实** 现状:只用 `rechargeLifecycle` 与当前用户判定。问题:先点 A 档再点 B 档时,A 的 `finally` 会在 B 仍 pending 时清掉 loading,确认页短暂回落到未打折原价且提交按钮不可用。 修复:新增 `membershipQuoteRequestRef` 序号令牌,只有最新请求能写报价 / 错误 / loading。提交 `5361d8963`。 - [x] 10. `useAccountWallet.ts:435-438` —— 会员下单传伪造字段。**已核实** 现状:`buyRechargeProduct(product: ProfileRechargeProduct)`,会员下单手写 `priceCents: 0`、`kind: 'membership'` 等一整套假数据,实际只用到 `productId`。问题:类型上给了「前端能定价」的假象。 修复:签名收窄为 `Pick<ProfileRechargeProduct, 'productId' | 'kind'>`。提交 `44952704b`。 - [x] 11. `scripts/admin-web-fake-api.mjs:458-460` —— 缺少会员档位 mock 路由。**已核实** 现状:假后端没有 `/admin/api/profile/membership-plans`,请求落到末尾 `return {}`。问题:新后台页拿到 `undefined` 的 `entries`,只显示列表错误,POST 也静默返回 `{}`。 修复:新增 GET 列表与 POST 回显。提交 `8cfa5a45a`。 - [x] 12. `scripts/admin-web-fake-api.mjs:462-463` —— 期号越界。**已核实** 现状:月付样例 `cycleKind: 'monthly'` + `cycleIndex: 2` + `cycleCount: 1`。问题:后端约定 `1 <= cycle_index <= cycle_count`,本地预览会渲染「第 2/1 期」。 修复:`cycleIndex` 改为 1。提交 `40c754ee0`。 - [x] 13. `PlatformProfileRechargeModal/index.tsx:1378-1385` —— 点击触发两次下单。**已核实** 现状:外层 `<div onClick>` 包着已经带 `onCheckout` 的套餐卡按钮。问题:点 CTA 时事件冒泡,`openMembershipCheckout` 执行两次。 修复:删掉外层 div 的 `onClick`。提交 `5f31c8c9d`。 - [x] 14. `module-runtime/membership/upgrade.rs:203-206` —— `ceil_div` 除零被静默吞掉。**已核实** 现状:分母为 0 时返回 0。问题:死防御,一旦常量被重构,程序员错误会变成「合法」的 0 元报价。 修复:加 `debug_assert!(denominator != 0)`,保留 release 下的 0 兜底。提交 `73b53a9dd`。 - [x] 15. `module-runtime/membership/upgrade.rs:135` —— 零时长账期窗口未被拒绝。**已核实** 现状:窗口校验用 `<`。问题:`cycle_resets_at == cycle_started_at` 能通过校验,金额只按剩余整月算。 修复:改为 `<=` 并补注释。提交 `adfae5ade`。 - [x] 16. `api-server/runtime_profile.rs:263-270` —— 会员业务拒绝被当成 502。**已核实** 现状:`map_runtime_profile_client_error` 只把兑换码的 7 条文案提升为 400,其余 `SpacetimeClientError::Procedure` 一律 502。问题:正常客户端错误都变成 502。 修复:新增 `is_runtime_profile_membership_domain_error`,把会员域文案映射为 400,基础设施错误仍保持 502;同一处也覆盖第 26 项。提交 `aca58b6b2`。 - [x] 17. `AdminMembershipPlanPage.tsx:325` —— 空标题仍可提交。**已核实** 现状:保存按钮只在 `isSaving` 时禁用。问题:标题为空 / 全空格时后端返回 `MissingProductTitle`,要点完确认弹窗才看到笼统错误。 修复:`disabled={isSaving || !title.trim()}`。提交 `a0c48d85a`。 - [x] 18. `apps/ai-game-creator-shell/src-tauri/desktop.rs:599` —— 新命令未进 allowlist。**已核实** 现状:`read_profile_membership_upgrade_quote` 经 `accountHost.ts` 调用,静态扫描解析不到字面量 `invoke('...')`。问题:`check-config.mjs` 直接报 `missing commands`,`agc:typecheck` 不过。 修复:在 `check-config.mjs` 的 allowlist 补上该命令。提交 `071cc3284`。 - [x] 19. `usePlatformProfileCenterController.ts:1359-1364` —— 报价请求竞态。**已核实** 现状:只用 signal 与账号生命周期判定。问题:切 A / B 档或点「重新获取报价」时,慢的旧响应会覆盖新报价并提前把 loading 置 false。 修复:新增 `membershipQuoteRequestRef` 序号令牌。提交 `3a998e15b`。 - [x] 20. `usePlatformProfileCenterController.ts:1392-1402` —— 会员下单传伪造字段。**已核实** 现状:与第 10 项同源,平台面板版本也手写一整套假数据。问题:实际只消费 `productId`(和 `kind`),`priceCents: 0` 会让人误以为客户端在定价。 修复:签名收窄为 `Pick<ProfileRechargeProduct, 'productId' | 'kind'>`。提交 `c1b96f6bf`。 - [x] 24. `usePlatformProfileCenterController.ts:400-406` —— 切换账号残留报价状态。**已核实** 现状:账号切换的重置 effect 只清 recharge / wallet / referral / rewardCode 等状态。问题:`membershipQuote` 可能保留上一个账号的报价;`isLoadingMembershipQuote` 因被中断请求的 `finally` 不再执行而卡在 `true`。 修复:重置 effect 补 `setMembershipQuote(null)` / `setIsLoadingMembershipQuote(false)` / `setMembershipQuoteError(null)`,并作废未完成报价令牌。提交 `873bd5bee`。 - [x] 26. `api-server/runtime_profile.rs:1217-1228` —— 会员档位目录校验失败被当成 502。**已核实** 现状:`admin_upsert_profile_membership_plan` 也走 `map_runtime_profile_client_error`。问题:管理员把档位改成价格 / rank 倒挂时收到 502。 修复:与第 16 项同一处提交 `aca58b6b2` 已覆盖(`会员档位…` 前缀 → 400)。 - [x] 1. 会员变更摘要悬空分隔符(bug · low,`apps/admin-web/src/pages/AdminRechargeOrderPage.tsx:1311-1314`) - 现状:`formatMembershipCycleKind(change.cycleKindAfter ?? change.cycleKindBefore ?? '')`,而 `formatMembershipCycleKind('')` 会把未知值原样返回,于是摘要被拼成 `${action} ${planPath} / ${cycle}`。 - 问题:`cycleKind` 缺失时渲染出 `新购 Pro / ` 这种悬空斜杠。 - 已修复(cf6004718):先取 `cycleKindAfter ?? cycleKindBefore`,仅在有值时才追加 ` / ${formatMembershipCycleKind(cycleKind)}`。 - [x] 2. 泥点商品列表为空时表单残留旧值(bug · low,`AdminRechargeProductPage.tsx:75-79`) - 现状:`refreshProducts` 只在 `sortedEntries[0]` 存在时 `fillForm`;过滤后为空就保持上一次表单值(或默认值)。 - 问题:商品被全部删除后,表单仍带着已删除商品的旧值且保存按钮可点,容易误存一条过期记录。 - 已修复(5157ec178):抽出 `DEFAULT_RECHARGE_PRODUCT_FORM`,列表为空时调用 `resetForm()` 回到新建默认值。 - [x] 3. 同名整数解析器语义分裂(maintainability · low,`AdminProfileWalletConfigPage.tsx:209-215` 与 `AdminRechargeProductPage.tsx`) - 现状:两个页面各自定义 `parseNonNegativeInteger`:账号配置页返回 `number | null`(非法即拒绝),充值商品页返回 `number`(负数/非法压成 0);会员档位页还有第三份 `parsePositiveInteger`。 - 问题:同名不同契约,后续容易改错一边造成校验漂移;另外旧的 `parseInt` 会把 `'12abc'` 截断成 `12`。 - 已修复(9f10423ff):在 `pageUtils.ts` 提供严格版 `parsePositiveInteger` / `parseNonNegativeInteger`(`Number` + `isSafeInteger`,返回 `number | null`)与落库宽松版 `...OrZero`,三个页面删除本地副本,统一走共享实现。 - [x] 4. 假后台保存会员档位后回显错档(maintainability · low,`scripts/admin-web-fake-api.mjs:768-769`) - 现状:假后台 POST 不读请求体,统一回显 `plans[1]`(starter)。 - 问题:本地预览保存非 starter 档位后,表单与列表被重置回 starter,upsert 联调结果失真。 - 已修复(dec44ec4e):`route()` 增加 `requestBody`,POST 时按 `plan` 找到目标行并用提交字段覆盖后回显;同时抽出 `UNLIMITED_CONCURRENCY_THRESHOLD` 常量。 - [ ] 5. 旧档位 token 无数据迁移(bug · high,`server-rs/crates/module-runtime/src/domain.rs:1262-1272`) - 现状:`RuntimeProfileMembershipPlan::parse` 只接受 `normal/starter/plus/pro/max`,旧 token `month/season/year/basic/ultimate` 直接返回 `None`。 - 复核更新:你已提交 `ba451bfc0`,把 `RuntimeProfileMembershipTier` 与旧 `tier` 列保留下来,schema 改为追加式兼容,避免发布时导出/清库;但运行时不读旧 `tier`,新 `plan` 列对存量行仍是默认 `Normal`。 - 问题:存量会员行会解码成 `Normal`,续期时按月额度归零,等于静默降级;`migration.rs` 当前没有 `profile_membership` 的 tier → plan 映射分支。 - 建议:在保持旧列不动的前提下加一次性回填(读旧 `tier` → 写等价 `plan` + `cycle_kind`),可做版本化 reducer 或迁移导入规范化。 - [x] 6. 会员档位“缺失”时引导原价新购(bug · medium,`packages/shared/src/components/PlatformProfileRechargeModal/index.tsx:362-364`)— 经你确认前提不成立,非问题 - 现状:`resolveMembershipPlanAction` 在 `!isActive || !membership || !currentPlan` 时返回 `开通会员`;`!membership` 在 `isActive` 为真时确实是死条件。 - 复核:会员档位是代码内目录的固定枚举,`membership_plan_records` 返回全部档位行(包括 `enabled=false`,只标记可购买状态,不删行),前端 `currentPlan` 由同一份列表按 `membership.plan` 查找。不存在“后台下架/删除某档位导致当前计划不在目录”的路径,因此 `currentPlan` 只有在目录整表为空(见 #25)时才为 null。 - 结论:无实际 bug;`!membership` 冗余条件可顺手清掉,但无行为影响,未改代码。 - [x] 7. 升级报价未按周期匹配(bug · medium,`PlatformProfileRechargeModal/index.tsx:930-933`) - 现状:`quoteForTarget` 只比对 `membershipQuote.targetPlan === target.plan`。 - 问题:同档位、不同周期的旧报价或加载中的陈旧报价会被当成命中,弹窗金额/赠送点与提交按钮状态都可能错误。 - 已修复(251475994):匹配条件补上 `membershipQuote.cycleKind === target.cycleKind`,周期不一致按“无报价”处理。 - [x] 8. 报价错误态嵌套三元(style · low,`PlatformProfileRechargeModal/index.tsx:1011-1012`) - 现状:`errorState` 内联 `error ? ... : isLoading ? ... : null` 的嵌套三元,违反项目风格约束。 - 问题:三层状态不可读,后续扩展容易继续嵌套。 - 已修复(803d5ccb0):抽出模块级 `resolveMembershipQuoteErrorState({ isUpgrade, error, isLoading, onRetry })`,用早返回表达三种状态。 - [x] 9. 方向键/Home/End 切换后焦点不跟随(maintainability · low,`PlatformProfileRechargeModal/index.tsx:484-487`) - 现状:键盘处理只 `onChange(nextTab)`,没有把焦点移到新选中的 tab;档位网格同样问题。 - 问题:roving tabindex 模式下键盘用户切换后焦点丢失,无障碍体验退化。 - 已修复(cec4b7ec5):引入 `tabRefs` / `productRefs`,在 Arrow/Home/End 计算完 `nextTab`/`nextProduct` 后 `onChange` + `focus()`。 - [x] 10. 默认预选第 3 档缺说明(maintainability · low,`PlatformProfileRechargeModal/index.tsx:1278-1279`) - 现状:`Math.min(2, pointProducts.length - 1)` 直接写死“第 3 档”。 - 问题:魔法下标没有业务解释,后人不敢改。 - 已处理(9af662551):保留现有默认行为(充点商品同价,仅影响首屏选中项),抽出 `defaultProductIndex` 并补中文注释说明为何选中间档;没有改成 `recommended`,因为充点商品契约当前没有推荐标记,贸然改会变更首屏默认选中项。 - [x] 11. 下单入口携带无人消费的 `kind`(maintainability · low,`apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts:435-438`) - 现状:`buyRechargeProduct(product: Pick<ProfileRechargeProduct, 'productId' | 'kind'>)`,调用处传 `kind: 'membership'`,但函数体只读 `product.productId`。 - 问题:后端通过 `parse_runtime_profile_membership_product_id(productId)` 推导商品类型,`kind` 是死代码,与注释“只需要把 productId 交给同一条下单链路”不符。 - 已修复(da0ef16df):`Pick` 收窄为 `'productId'`,移除 `kind: 'membership'`。 - [x] 12. 升级报价先做数值校验导致错误码不对(bug · medium,`server-rs/crates/module-runtime/src/membership/upgrade.rs:164-165`) - 现状:`validate_runtime_profile_membership_upgrade_quote_input` 先按 `target.cycle_kind.cycle_count()` 约束 `cycle_index`,之后才做领域检查 `check_runtime_profile_membership_upgrade_allowed`。 - 问题:年付 → 月付且 `cycle_index > 1` 时,目标月付 `cycle_count() == 1` 先让数值校验失败,调用方拿到 `InvalidQuoteInput` 而不是语义正确的 `CycleKindChangeUnsupported`;现有测试只覆盖月→年方向(`cycle_count() == 12`)所以没暴露。 - 已修复(1811b2206):先跑领域检查再做数值校验,并补年→月 `CycleKindChangeUnsupported` 测试。 - [x] 13. 档位校验未覆盖展示型权益(maintainability · low,`membership/catalog.rs:162-167`) - 现状:`validate_runtime_profile_membership_plan_row_against_catalog` 只校验 rank 唯一与月价/年价/泥点单调。 - 问题:`model_access` 与 `concurrent_job_limit` 不在校验内,后台可存出高档位但权益更差的倒挂目录;代码又确实把它们当展示字段。 - 已处理(78a43b6a1):这是“有意只做展示”的设计,按评审建议补文档注释,明确这两维是展示/判断字段、不做单调校验;没有新增校验以免误伤现有可编辑目录。 - [x] 14. `membership-plans` Tab 未登记导致非 owner 恒 403(bug · high,`server-rs/crates/api-server/src/admin.rs:2241`) - 现状:路由要求 Tab 权限 `membership-plans`,但 `shared-contracts/src/admin.rs` 的 `ADMIN_TAB_PERMISSIONS` 没有它。 - 问题:管理员账号创建/更新会校验 Tab 白名单,未知值被拒,于是任何非 owner 管理员都拿不到该 Tab,新会员档位页实际变成 owner-only。 - 已修复(c9ad084f7):`ADMIN_TAB_PERMISSIONS` 增加 `membership-plans`、长度 21 → 22;并给 `every_assignable_admin_tab_has_a_backend_route` 加断言,路由用到的每个 Tab 必须在白名单内,防止再次漏登记。 - [x] 15. 死错误变体 `InvalidRechargeProductDuration`(maintainability · low,`server-rs/crates/module-runtime/src/commands.rs:421-424`) - 现状:旧 `duration_days == 0` 校验被移除后,`RuntimeProfileFieldError::InvalidRechargeProductDuration` 已无构造点,但枚举和 Display 文案(“会员商品 duration_days 必须大于 0”)仍在。 - 问题:错误枚举对外暴露一条不存在的校验路径。 - 已修复(ef99e35d2):删除该变体及其 Display 分支,全仓确认无其他引用。 - [ ] 16. `RuntimeProfileMembershipTier` 改名属破坏性 schema/线格式变更(bug · high,`server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_type.rs:10-20`) - 现状:生成绑定一度删除了 `RuntimeProfileMembershipTier` 及其 `Month/Season/Year/Basic/Ultimate`,快照改为 `plan/cycle_index/cycle_count/cycle_kind`。 - 复核更新:你已提交 `ba451bfc0`,恢复 `RuntimeProfileMembershipTier`、旧 `tier`/`cycle_period_days` 与会员商品兼容列并重新生成绑定,schema guard 不再需要 allow-breaking,发布不再触发导出/清库/回灌。 - 剩余风险:旧生成客户端仍不认识新增的 `plan`/`cycle_kind` 枚举值;且旧 `tier` → 新 `plan` 的数据回填仍然缺失(同 #5/#27)。 - 建议:保留追加式兼容的同时补数据回填;旧客户端的解码窗口按实际灰度要求决定是否保留。 - [x] 17. `updated_at_micros` 由调用方写入(bug · medium,`.../runtime_profile_membership_plan_admin_upsert_input_type.rs:22`)— 经复核为非问题 - 现状:模块 procedure `admin_upsert_profile_membership_plan` 接收带 `updated_at_micros` 的输入并原样落库。 - 复核:该 procedure 由 `require_editor_generation_runtime_service_identity` 限定为服务身份,客户端无法直连;唯一调用方 `api-server` 的 `admin_upsert_profile_membership_plan`(`runtime_profile.rs:1210`)用 `OffsetDateTime::now_utc()` 现场生成该值,请求体里的同名字段不会被透传。 - 结论:不存在“管理员客户端可写任意时间戳”的路径,未改代码。若后续把该模块函数直接暴露给客户端,需要重新评估。 - [x] 18. 升级报价快照用 `String` 而非枚举(maintainability · low,`.../runtime_profile_membership_upgrade_quote_snapshot_type.rs:9-12`) - 现状:`RuntimeProfileMembershipUpgradeQuoteSnapshot` 的 `current_plan`、`target_plan`、`cycle_kind` 原为 `String`(`module-runtime/src/domain.rs:1894`),而 `RuntimeProfileMembershipPlan` / `RuntimeProfileMembershipCycleKind` 枚举已存在。 - 问题:类型层面允许非法值,解析/比较责任被推给消费方。 - 已修复(5603185a7):三个字段改为对应枚举;模块构造点直接写入枚举,记录侧在 `build_runtime_profile_membership_upgrade_quote_record` 用 `as_str()` 转回对外 token(HTTP 契约不变);`spacetime-client` mapper 改用 back 映射还原枚举,并重新生成 `module_bindings`。 - [x] 19. 后台会员页硬编码并发哨兵 128(maintainability · medium,`apps/admin-web/src/pages/AdminMembershipPlanPage.tsx:35-36`) - 现状:页面自定义 `UNLIMITED_CONCURRENT_JOB_LIMIT = 128`,并在表单标签、校验文案、`formatPlanLimits` 三处比较;但后端 `build_profile_membership_plan_response` 已经下发派生的 `unlimitedConcurrency`。 - 问题:后端哨兵一旦调整,后台展示会静默失真。 - 已修复(44bbc846a):`ProfileMembershipPlanAdminResponse` 补 `unlimitedConcurrency`,列表展示改读该标志;`ProfileMembershipPlanAdminListResponse` 增加 `unlimitedConcurrencyThreshold` 并由 `api-server` 下发 `MEMBERSHIP_UNLIMITED_CONCURRENCY`,表单标签与校验文案改读它;本地假后台同步补齐两个字段。 - [x] 20. “本次到账”未体现首充赠送(bug · low,`PlatformProfileRechargeModal/index.tsx:902-905`)— 首充赠送已退役,非问题 - 现状:结算弹窗 `本次到账` 只显示 `product.pointsAmount`,`PlatformPointProductGrid` 也只显示 `pointsAmount`;契约与后台仍保留 `bonusPoints` 字段。 - 复核:首充赠送已退役(`module-runtime/src/lib.rs:968`「新方案取消首充赠送,六档充点一律无赠送、无角标」;`commands.rs:417` 对泥点商品 `duration_days != 0 || bonus_points != 0` 直接返回 `InvalidRechargeProductFields`),所以 `bonusPoints` 恒为 0,评审补丁反而会展示不存在的赠送。 - 结论:展示 `pointsAmount` 是正确的,未改代码。遗留的 `bonusPoints` 字段(后台表单/契约/`resolve_runtime_profile_points_recharge_delta`)属可选清理,确认要退役可单独删。 - [x] 21. 钱包入口与充值弹窗测试断言过期(test · high,`apps/ai-game-creator-shell/tests/appSurface/home.suite.ts:776-778`) - 现状:`PlatformMudPointWalletEntry` 的余额按钮 aria-label 已改为 `泥点账户:可用总额 …`、充值按钮文案改为 `会员与泥点`;充值弹窗标题改为 `会员与泥点`、金额行改为 `可用总额 … 泥点`,并新增了“泥点充值”页签与“确认订单”二次确认弹窗。 - 问题:旧断言 `/^泥点 /`、`'充值'`、`'购买更多泥点'`、`'当前余额 …'`、`/60泥点.*购买/` 全部失配,测试在改动前就已经在 `home.suite.ts:1013` 失败。 - 已修复(066d14636):钱包入口/充值按钮改用新可访问名;充值流程补上切换“泥点充值”页签、在“确认订单”弹窗内点击、以及新的余额文案断言。已跑 `starts from the client home` 与 `opens the shared recharge modal` 两条用例通过。 - [x] 22. 升级报价未校验到期日(maintainability · low,`server-rs/crates/module-runtime/src/membership/upgrade.rs:152-153`) - 现状:输入自检校验了周期窗口与 `now`,但没有校验 `expires_at_micros`。 - 问题:允许 `expires_at_micros < cycle_resets_at_micros`(甚至已过期)仍拿到成功报价,报价结果内部自相矛盾;而该函数同时服务报价与下单路径。 - 已修复(1d2574a43):加入 `expiry_out_of_order = input.expires_at_micros < input.cycle_resets_at_micros` 并拒绝为 `InvalidQuoteInput`,补对应测试。 - [x] 23. `beijing_local_micros` 用 0 兼作非法哨兵(maintainability · low,`server-rs/crates/module-runtime/src/membership/cycle.rs:24`) - 现状:函数在非法输入时返回 `0`,但北京 1970-01-01 08:00 本身就是合法的 `0`。 - 问题:调用方无法区分“非法日期”和“合法 epoch”,`advance_beijing_months_clamped` 会把 `0` 当作锚点继续算。 - 已修复(3db258d92):`beijing_local_micros` 改为返回 `Option<i64>`;`advance_beijing_months_clamped` 对夹取后的合法输入 `expect`;测试助手同步更新,并补“合法 epoch 0 可被区分”的用例。 - [x] 24. 会员业务拒绝被映射成 502(bug · medium,`server-rs/crates/api-server/src/runtime_profile.rs:1511-1522`) - 现状:`is_runtime_profile_membership_domain_error` 只匹配 `会员升级被拒绝: `、`会员档位` 前缀和一组精确文案。 - 问题:`充值商品类型无效:会员商品已迁移到会员档位目录`(后台仍 POST `kind: "membership"`)与 `会员商品 product_id 无法解析: …`(存量待支付会员订单)这类可读业务拒绝落到 502 BAD_GATEWAY。 - 已修复(3f9b28070):补上这两个前缀匹配,并新增 `membership_business_rejections_map_to_bad_request` 覆盖升级拒绝、商品 id 解析失败、旧会员商品类型三条路径;目录缺行仍按部署配置错误保持 502。 - [x] 25. 已有库不会补种会员档位目录(bug · medium,`server-rs/crates/spacetime-module/src/runtime/active/profile.rs:8850-8853`) - 现状:`ensure_default_profile_membership_plan` 只在 `#[reducer(init)]`(仅建库时执行)与后台 upsert 时调用,读取路径(充值中心、下单快照、升级报价)不再懒播种。 - 问题:对存量数据库 republish 后 `profile_membership_plan` 为空,充值中心无会员档位,下单/报价返回 `会员目录缺少档位`,直到管理员手动 upsert 一次才补齐。 - 已修复(888a94ac8,你提交):`membership_plan_row` / `membership_plan_records` 恢复懒播种,下单校验、升级报价、充值中心读取目录前先补种代码内种子目录。 - [x] 26. 过期的新购结算会覆盖更新的会员(bug · medium,`server-rs/crates/spacetime-module/src/runtime/active/profile.rs:9954-9960`) - 现状:冻结的 `Purchase` 结算在下单时用户无有效会员,但结算时若 `active_row` 已存在,会直接走 `apply_profile_membership_fresh_purchase`(按 `user_id` 删除后重插)。 - 问题:用户在订单创建与支付之间通过另一订单获得了更高级/更长会员时,这笔旧订单会静默覆盖新会员,可能降档并丢失已付时长;`Upgrade` 分支有 `plan_before`/`cycle_kind_before` 守卫,`Purchase` 分支没有。 - 已修复(0fc564c4e):`change_kind == Purchase` 且 `active_row.is_some()` 时返回「会员状态已变化,请重新下单」;历史无快照的重算路径只会在 `active_row` 为空时产出 `Purchase`,不受影响,错误经既有分类器按 400 返回。 - [x] 28. 升级结算缺少周期身份快照(bug · medium,`profile.rs:9931-9935`) - 现状:冻结 `Upgrade` 结算只对比 `plan_before`/`cycle_kind_before`,`ProfileMembershipOrderSnapshot` 没有记录下单时的 `cycle_index`/`cycle_started_at`/`cycle_resets_at`。 - 问题:若下单到支付之间会员周期已推进,`apply_profile_membership_upgrade` 会把按旧周期剩余比例算出的 `grant_points` 叠加到已重置的新周期 `cycle_remaining_points` 上,造成多发泥点。 - 已修复(5826b2e56):按你说的“跟 Purchase 一样用快照”,`ProfileMembershipOrderSnapshot` / `ProfileMembershipSettlement` 增加 `cycle_index_before` 与 `cycle_resets_at_before_micros`,建单写入订单已有的会员快照列(不新增 schema 字段);支付前用纯函数 `frozen_membership_cycle_matches` 比对,账期已推进即拒绝并要求重新下单。历史无快照订单冻结值为 `None`,跳过校验。 - [x] 1. apps/admin-web/src/pages/pageUtils.ts:36-42 —— 「严格」整数解析实际接受小数/科学计数法/十六进制 - 现状:`parsePositiveInteger` / `parseNonNegativeInteger` 先用 `value.trim() === ''` 判空,再用 `Number(value)` + `Number.isSafeInteger`。 - 问题:`Number` 会把 `'1.0'`→1、`'1e2'`→100、`'0x10'`→16、`' 42 '`→42、`'+5'`→5 都变成安全整数,与「严格整数」注释不符;后台价格 / 点数 / 并发上限保存可被这类输入穿透。 - 处理:已修复(`658a39a35`)。改为对 trim 后的字符串做 `^\d+$` 校验,只接受纯十进制数字;`'+5'` 现在也会被拒绝。 - [x] 2. apps/admin-web/src/pages/pageUtils.ts:53-61 —— 四个 helper 重复且两个宽松版行为完全等价 - 现状:`parsePositiveInteger('0')` 返回 `null`,经 `?? 0` 后与 `parseNonNegativeIntegerOrZero('0')` 都是 `0`;对任意输入两者结果相同。 - 问题:重复实现,且名字暗示的「正 / 非负」差异在宽松版本里并不存在,容易误导后续调用者;改一处漏一处。 - 处理:已修复(`8ad490b39`)。抽出内部 `parseSafeIntegerText`,四个导出函数改为薄包装;两个 OrZero 语义等价已写进注释。保留导出名以免改动调用点;是否真正合并成一个由你决定。 - [x] 8. packages/shared/src/components/PlatformProfileRechargeModal/index.tsx:739 —— 嵌套三元 - 现状:按钮 children 为 `disabled ? actionLabel : submitting ? '处理中' : '选择并确认'`。 - 问题:嵌套三元不易读,disabled / submitting 优先级不直观。 - 处理:已修复(`987e82605`)。在 return 前用 if/else 计算 `actionButtonLabel`。 - [x] 9. packages/shared/src/components/PlatformProfileRechargeModal/index.tsx:519-527 —— 方向键索引逻辑重复 + 嵌套三元 - 现状:页签 tablist 与同文件的 `PlatformPointProductGrid` 各写了一份完全相同的 Home/End/左右键循环索引逻辑,且都是嵌套三元(评审说的「在 PlatformPointProductGrid 里重复」实际是同一文件内的重复,不是跨文件)。 - 问题:重复 + 难读,改一处漏一处。 - 处理:已修复(`93837a736`)。抽出模块级 `resolveRovingIndex(key, currentIndex, length)`(switch 实现),两处共用;行为不变,组件测试通过。 - [x] 10. packages/shared/src/components/PlatformProfileRechargeModal/index.tsx:991-993 —— 升级报价未到时把标准价当「本次补付」 - 现状:`amountCents = quoteForTarget ? quote.amountCents : listPriceCents`;升级确认页在报价加载中 / 失败时,大金额区显示新套餐标准价并标「本次补付」,CTA 也带这个价。 - 问题:补差金额几乎总小于标准价,展示会严重误导用户。 - 处理:已修复(`951b44b58`)。新增 `amountPending = isUpgrade && !quoteForTarget`:pending 时金额区显示 `—`,CTA 只显示「补差升级」并保持禁用(`canSubmit` 仍要求 `quoteForTarget`)。已补回归测试。注意:明细行里的「新套餐标准价」仍显示标准价,那是正确信息。 - [x] 16. packages/shared/src/components/PlatformProfileRechargeModal/index.tsx:1147-1151 —— 静态支付方式被标成可交互 radiogroup - 现状:支付方式用 `role="radiogroup"` 包一个不可交互的 `role="radio" aria-checked="true"` 的 span(恒为微信支付)。 - 问题:读屏会播报一个无法更改的已选单选项,误导用户。 - 处理:已修复(`582a32946`)。去掉 radiogroup / radio 语义,改为普通文本容器,样式不变。 --- ## 脚本 - [x] 11. scripts/admin-web-fake-api.mjs:8 —— 硬编码「不限并发」哨兵无来源指引 - 现状:`UNLIMITED_CONCURRENCY_THRESHOLD = 128`,注释只说与后端一致,没有路径。 - 问题:后端常量改动后本地假数据会静默漂移,「不限并发」显示错误。 - 处理:已修复(`e855c52d7`)。注释补上后端文件 `server-rs/crates/module-runtime/src/membership/limits.rs` 与常量名 `MEMBERSHIP_UNLIMITED_CONCURRENCY`。真正的单一来源需要生成脚本 / 共享常量(更大改动),未做。 --- ## 后端(server-rs) - [x] 6. server-rs/crates/module-runtime/src/membership/catalog.rs:151-154 —— 单调校验未声明「existing 必须完整」 - 现状:`validate_runtime_profile_membership_plan_row_against_catalog` 只遍历 `existing` 里出现的行逐对比较,且从 crate 根 re-export。 - 问题:若调用方传入被过滤 / 不完整的 existing,缺失档位造成的倒挂会静默通过,之后才在报价或目录读取时暴露。 - 处理:已修复(`b23ed023f`)。补文档前置条件:existing 必须覆盖除 candidate 外的全部在售档位,并说明缺失会静默通过。未加运行时断言,避免影响现有调用方。 - [x] 7. server-rs/crates/module-runtime/src/membership/cycle.rs:62-63 —— `index.max(1)` 与报价侧的严格校验不一致 - 现状:`membership_cycle_window` 用 `index.max(1)`,`index = 0` 收敛为第 1 期;而 `quote_runtime_profile_membership_upgrade` 显式把 `cycle_index == 0` 判为 `InvalidQuoteInput`。已有测试固化了 0→第 1 期。 - 问题:同一套 1-based 契约两处处理不一致,可能掩盖调用方的期号错误。 - 处理:已修复(`a16dbf7bc`),选择「文档化」而非改行为:注明 0 被收敛为第 1 期是为兼容「期号水位未初始化」的存量行,与报价的严格拒绝不同。若你要统一成拒绝,需同步改测试与所有调用方。 - [x] 12. server-rs/crates/api-server/src/runtime_profile.rs:1831-1834 —— 微信虚拟支付未校验档位是否在售 - 现状:会员档位只校验 `center.membership_plans` 里存在 `item.plan == plan`;该目录快照包含全部行(含下架与 `Normal` 占位)。 - 问题:已下架档位或 `Normal` 占位仍能通过并进入微信虚拟支付签名流程。 - 处理:已修复(`4a2594a90`)。判定改为 `item.plan == plan && item.enabled && item.plan.is_purchasable()`,与 `resolve_enabled_profile_recharge_order_product` 的可售口径一致(`Normal` 目录里 `enabled=false` 且 `is_purchasable()=false`)。 - [x] 13. server-rs/crates/module-runtime/src/civil_calendar.rs:39 —— `saturating_add` 静默产出错误日期 - 现状:`civil_from_days` 用 `date_key.saturating_add(719_468)`;极端日序号会饱和,返回格式合法但错误的年月日。 - 问题:公共纯函数对越界输入静默给出错误结果。 - 处理:已修复(`b0b1e2519`)。按建议文档化输入范围(约 ±7.9e11 天,即 i32 年份区间)与饱和行为,要求调用方保证范围。未改成 `Option`(会破坏公开签名与调用方),是否改成返回 `Option` 由你决定。 - [x] 17. server-rs/crates/module-runtime/src/membership/cycle.rs:36-39 —— 文档说越界返回 None,实现却饱和 - 现状:`beijing_local_micros` 文档承诺「本地日期/时刻无法表示时返回 None」,但时间戳构造用 `saturating_mul/add/sub`;年份导致 i64 溢出时返回饱和后的 `Some`。 - 问题:调用方拿到「看似合法」的损坏时间戳。 - 处理:已修复(`cc30d8ba7`)。按评审补丁改为 `checked_mul/checked_add/checked_sub?`;新增测试断言 ±1_000_000 年返回 `None`。 - [x] 22. server-rs/crates/spacetime-module/src/runtime/active/profile.rs:8386-8388 —— 目录缺行回退 `rank = 0` - 现状:后台 upsert 档位时 `resolve_runtime_profile_membership_plan_row(plan).map(|r| r.rank).unwrap_or(0)`。 - 问题:目录缺行会被写成 `rank = 0`,与 `Normal` 同档,掩盖未来枚举 / 目录不一致;module-runtime 的对应文档明确警告不要 `unwrap_or(0)`。 - 处理:已修复(`44feb7b0f`)。缺行改为显式报错(`会员目录缺少档位 X,无法解析升级比较基准`)。 --- ## 留给你处理的破坏性 / 高风险项 - [x] 3. server-rs/crates/shared-contracts/src/admin.rs:1253 —— `granted_points_delta` 有符号/无符号不一致(按你的决定:对齐 u64) - 现状:`AdminMembershipOrderChangePayload.granted_points_delta: i64`(admin.rs:1253),对应 `RuntimeProfileMembershipOrderChangeSnapshot.granted_points_delta: i64`(domain.rs:1753)与表列 `profile_recharge_order.membership_granted_points_delta: i64`(profile.rs:571);而报价侧 `ProfileMembershipUpgradeQuoteResponse.granted_points_delta: u64`(runtime.rs:706)、`RuntimeProfileMembershipUpgradeQuoteRecord` 也是 `u64`。 - 问题:同一个「本期补点」量在不同层有符号 / 无符号不一致,对账需要 lossy cast,也容易漂移。 - 处理:已修复(`1744262b0`)。表列 `membership_granted_points_delta`、module-runtime 快照、admin DTO 全链路改 `u64`,删除 `as i64` 与 `u64::try_from` 兜底;重生成绑定。**SpacetimeDB schema 类型变更**:本地若已有旧库,publish 时可能要求 `--delete-data`;你已确认没有线上数据、不需要迁移,所以未写回填。 - [x] 4. server-rs/crates/shared-contracts/src/runtime.rs:732-733 —— 封闭取值域用裸 `String`(按你的决定:全部枚举化) - 现状:`AdminUpsertProfileMembershipPlanRequest.plan: String`、`model_access: String`,注释明确本期不做服务端校验;其他新契约的 `change_kind` / `cycle_kind` 也是 String。 - 问题:后台可写入任意 token(例如拼错 `"ful"`),前端 union 静默回退 fallback 文案,错误被持久化。 - 处理:已修复(`d7dec9b47`;配套 token 枚举在 `c660ca41f`)。新增 `ProfileMembershipPlanToken` / `ModelAccessToken` / `CycleKindToken` / `ChangeKindToken`(`serde(rename_all = "snake_case")`,与 TS union 对齐),`AdminUpsertProfileMembershipPlanRequest.plan/model_access` 改为 token 枚举;非法 token 在反序列化阶段被拒(Axum 返回 4xx),后台不能再持久化任意字符串。`ProfileMembershipUpgradeQuoteRequest.target_plan` 也一并枚举化。注意:非法 token 的状态码由原来的 400 变为 Axum 的 422,前端只发合法 union 值,正常路径不受影响。 - [x] 5. server-rs/crates/module-runtime/src/domain.rs:1539 —— 缺一次性的 `tier → plan` 回填(按你的决定:不做,线上无数据) - 现状:`ProfileMembership` 保留退役列 `tier: RuntimeProfileMembershipTier`(profile.rs:444,运行时不读不写),新列 `plan: RuntimeProfileMembershipPlan` 带 `#[default(Normal)]`(profile.rs 462-463)。全仓确认:没有任何 reducer / 迁移把 `tier` 映射到 `plan`。 - 问题:若存在存量在册会员,发布后 `plan` 会取默认值 `Normal` 而 `status` 仍是 `Active`,等于丢失档位 / 权益 / 升级资格。 - 处理:按你的决定「5 not online no need」跳过——当前没有线上数据,不需要回填;`Month`/`Season`/`Year` 的旧映射问题也随之作废。如果将来引入存量库,需要在这一步补一次性回填 + 幂等标记 + 迁移测试。 - [x] 14. server-rs/crates/module-runtime/src/domain.rs:1746-1750 —— 快照里的枚举字段被字符串化(按你的决定:全部枚举化) - 现状:`RuntimeProfileMembershipOrderChangeSnapshot` 的 `change_kind: String`、`plan_before/after: Option<String>`、`cycle_kind_before/after: Option<String>`;底层 `profile_recharge_order` 表列其实是 typed enum,mapper 用 `as_str().to_string()` 降级成小写 token。 - 问题:字符串化把解析 / 校验推给每个消费者,与保留 typed enum 的 `RuntimeProfileMembershipRecord` 不一致;新增枚举变体若漏改 `as_str()` / `parse()` 会静默发错 token。 - 处理:已修复(`c660ca41f`)。快照字段改为 `RuntimeProfileMembershipChangeKind` / `Option<RuntimeProfileMembershipPlan>` / `Option<RuntimeProfileMembershipCycleKind>`;`RuntimeProfileMembershipUpgradeQuoteRecord` 的 `current_plan` / `target_plan` / `cycle_kind` 同步枚举化;shared-contracts 的 admin payload 与 membership / plan / quote 响应改用 wire token 枚举,api-server / spacetime-client / spacetime-module 的 `as_str().to_string()` 全部去掉。JSON wire 值不变(仍是小写 token)。 - [x] 15. server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_order_change_snapshot_type.rs:10-14 —— 生成绑定重复了字符串契约(随第 14 项再生) - 现状:该文件由 SpacetimeDB 生成,字段是 `String` / `Option<String>`,与第 14 项同源。 - 问题:生成绑定把枚举状态再编码成一套小写字符串契约,需要客户端维护并行解析。 - 处理:已修复(`c660ca41f`)。在 module 源码 `domain.rs` 改完后用 `npm run spacetime:generate` 重新生成,绑定字段现在直接引用 `RuntimeProfileMembershipChangeKind` / `Plan` / `CycleKind` 枚举,未手改生成文件。`spacetime:generate` 会额外产生 ~18 个与本项无关的 `*_and_return_procedure.rs` 纯格式差异(rustfmt 版本漂移),已逐个 revert,只保留语义相关的绑定改动。 - [x] 18. server-rs/crates/api-server/src/runtime_profile.rs:1512-1525 —— 用中文错误字符串前缀决定 400 / 502(按你的决定:全部改成 typed error) - 现状:`is_runtime_profile_membership_domain_error` 靠 `starts_with("会员升级被拒绝: ")`、`starts_with("会员档位")` 等前缀加中文精确匹配列表,把模块错误映射成 400 / 502。 - 问题:脆弱的字符串契约:任何新的会员领域拒绝若以裸 `Err("...")` 发出,就会被当成 502 基础设施错误,客户端看到基础设施失败而不是可读业务文案。模块侧已经有 `RuntimeProfileMembershipUpgradeRejection` / `RuntimeProfileError` 可集中承载。 - 处理:已修复(`68c8544a1`)。新增 `RuntimeProfileMembershipErrorCode`(11 个拒绝码)与内部 `RuntimeProfileMembershipDomainError { code, message }`;会员报价 / 充值中心(下单 / 确认支付)/ 后台档位 upsert 的 procedure 结果末尾追加 `error_code: Option<...>`,拒绝点显式赋码,中文 `error_message` 逐字不变(已用全量中文串比对确认 `MESSAGES-IDENTICAL`)。`spacetime-client` 新增 `SpacetimeClientError::ProcedureRejected { code, message }`;`api-server` 删除会员中文前缀分类,改按错误码返回 400,普通 `Procedure` 字符串错误与基础设施错误仍 502(测试新增「同样的中文文案没有 error_code 时落 502」断言)。重生成绑定,只保留语义相关文件、revert 18 个纯格式文件。实际改动由子 agent 完成,我复核了 diff、逐字文案、错误码落点,并独立重跑全部检查。 - 行为变化:`会员目录缺少档位 ...` 与「已迁移的会员商品」原先落 502,现在有明确错误码统一 400。兼容边界:procedure result 追加字段属 wire 契约变更,要求 module 与 api-server 同版本部署。兑换码链路本轮未改造,`api-server` 仍留一个兑换码文案匹配函数,已按你要求加 `TODO(refactor): 兑换码链路尚未接入 typed error code`(`fdf636ce9`)。 - [x] 19. server-rs/crates/spacetime-module/src/runtime/active/profile.rs:9421 —— `advanced` 把「初始化」和「真正换期」混为一谈(按你的决定:修复) - 现状:`advance_profile_membership_cycle_to` 里 `advanced = row.cycle_resets_at.is_none() || row.cycle_index == 0`;只要 `advanced` 为真就无条件把 `cycle_granted_points` / `cycle_remaining_points` 重写为 `period_points`,并触发 `membership-period-reset` ledger。 - 问题:`cycle_index == 0` 是新列默认值,任何不是由新购买路径写入的行(例如 `cycle_resets_at` 已有但期号仍为 0、或旧数据)都会被强制 `advanced = true`;即使 `at` 还在第一期窗口内,也会静默重置本期余额,等于凭空产生一次换期。 - 处理:已修复(`168028d5e`)。`advanced = row.cycle_resets_at.is_none()`;`cycle_index == 0` 只补写水位 `row.cycle_index = 1`,不重置 `cycle_granted/remaining_points`,真正越过边界才重置;补测试覆盖「账期窗口已存在但期号水位为 0」时保留本期余额。 - [x] 20. server-rs/crates/spacetime-module/src/runtime/active/profile.rs:9331-9332 —— 「只读」procedure 会写库(按你的决定:选方案 a / 第 1 条) - 现状:`membership_plan_row` 读取前会 `ensure_default_profile_membership_plan(ctx)` 懒播种(表为空时整表插入);只读快照 / 报价路径也走这个 helper。而 `init_profile_membership_plan_catalog` 的文档写「只读入口不再调用 ensure... 写库」,`build_profile_membership_snapshot_readonly` 注释也写「不写库」。 - 问题:注释与实现矛盾,调用方无法假定这些 procedure 无副作用;首次读取会写库。 #### 先回答你的疑问:`init 播种` 是什么?它懒吗? - 「`init 播种`」= SpacetimeDB 的 `#[reducer(init)] pub fn init_profile_membership_plan_catalog(...)`,它在**数据库第一次被创建(首次 publish)时执行一次**,把代码内目录灌进 `profile_membership_plan` 表。它**不是懒的**:那是发布时立即发生的 eager 播种。 - 「懒播种」= `ensure_default_profile_membership_plan(ctx)` 被 `membership_plan_row` / `membership_plan_records` 这类**读取 helper** 调用,只有读的时候发现表为空才补写。它「懒」是因为实际写库推迟到第一次读取,而不是在 init 时完成。 - 为什么要有懒播种:`init` reducer **只在数据库创建时跑一次**,后续增量发布(给已存在的库加表 / 加字段)不会重新触发 init。所以「库里已有 `profile_membership_plan` 表但当时 init 没种过」这种存量库,如果只靠 init,目录会一直为空;懒播种是给这类库兜底。代价就是「只读」procedure 首次调用会写库,和注释矛盾。 - 所以这条不是「改个注释」就能了事:`ensure_default_profile_membership_plan` 的懒播种注释和只读入口注释分别描述了两种设计意图,代码同时满足不了。**需要你先决定播种策略**,三条路选一: 1. 保持懒播种,把「只读 procedure 首次会写库」写进文档(承认副作用); 2. 去掉懒播种、只靠 init + 发布期 migration 播种(前提是发布流程会保证存量库也被种上); 3. 只读投影不落表,直接在内存里回退到代码内目录(`membership_plan_row` 查不到时返回代码默认值,可读路径全部无副作用)。 - 处理:已按第 1 条落地(`9d7ca1b3b`)——保留懒播种,把副作用写清楚:`ensure_default_profile_membership_plan` 注释说明它由 `init` 与读取 helper 共用、目录为空时首次读取会写库、表非空后幂等只读;`init_profile_membership_plan_catalog` 注释改为明确「不覆盖表已存在但为空的存量库」;报价与建单注释去掉「不写库」的绝对说法并注明懒播种例外;同步设计文档 §7/§10.2 与决策日志。行为不变(只改注释 / 文档)。 - [x] 21. server-rs/crates/spacetime-module/src/runtime/active/profile.rs:555-557 —— 旧迁移包缺新列会导入失败(按你的决定:不做迁移;先解释什么是「旧迁移包」) - 现状:`ProfileRechargeOrder` 新增 14 个 `membership_*` 快照列,`ProfileMembership` 新增 `plan` / `cycle_index` / `cycle_count` / `cycle_kind`;`normalize_migration_row` 对 `profile_recharge_order` 只回填 `provider_transaction_id` / `expired_at` / `expiration_*`,没有这些新列,也没有 `profile_membership` 分支。 - 问题:`row_from_json` 用 `serde_json::from_value` 反序列化,`#[default]` 只作用于 SpacetimeDB 活 schema 迁移、不作用于 JSON 反序列化;`recover_row_with_deleted_fields` 只丢弃多余字段、不补缺失字段。因此导入旧迁移包时 `profile_recharge_order`(以及 `profile_membership`)会因 `missing field` 失败。 #### 先回答你的疑问:`旧迁移包` 是什么? - 「迁移包」= 用 module 的 `export_database_migration_to_file` procedure 把整库数据导出的一个 JSON 文件(配套 `import_database_migration_from_file` / `import_database_migration_incremental_from_file` 及分块版本),用途是把一个 SpacetimeDB 库的数据搬到另一个库(换机器 / 换实例 / 本地灌数据)。文件里带 `MIGRATION_SCHEMA_VERSION`(当前 `= 1`,见 `migration.rs`),版本对不上会被 `parse_migration_file` 直接拒绝。 - 「旧迁移包」= 在**这次新增 `membership_*` / `plan` / `cycle_*` 列之前**、用旧版 module 导出、但 `schema_version` 仍是 1 的 JSON。它的每行 JSON 里根本没有这些新列;导入时 `serde_json::from_value` 不会用 `#[default]` 补,于是 `missing field` 报错。也就是说:只要你还想用「旧库导出的包」灌进「新 schema 的库」,就必须在 `normalize_migration_row` 里补这些列的缺省回填。 - 处理:按你的决定「21 no need migration」跳过——没有需要搬运的存量数据,就不补回填块。将来若要用旧导出包灌新库,再为 `profile_recharge_order` 和 `profile_membership` 补 `or_insert(Null / 0 / 默认枚举)`,且 `profile_membership.plan` 的回填值要与第 5 项的回填结论一致。 --- 附注: - 原文件末尾的 “LLM retry report summary / Context compaction” 是评审生成过程的元信息,不是代码问题,本次不再保留。 - 复核中发现的过时 / 不准确结论已在正文标注(第 9 项重复范围、第 5 项遗漏 `Month`/`Season`/`Year` 映射、第 20 项建议与懒播种注释冲突)。 - 第 18 项改动量较大(跨 module-runtime / spacetime-module / 生成绑定 / spacetime-client / api-server),实现由子 agent 完成,我复核了完整 diff、用全量中文串比对确认文案逐字未变、逐点核对错误码落点,并独立重跑了下方全部检查。 - 本轮改动已跑:`tsc` guardrails、组件 `eslint` + `prettier`、`vitest`(PlatformProfileRechargeModal 9 项)、`cargo check --workspace`、`cargo test -p api-server runtime_profile::tests`(41 passed)、`cargo test -p module-runtime --lib membership::`(39 passed)、`cargo test -p module-runtime -p api-server -p shared-contracts -p spacetime-module --no-run`、`npm run check:encoding`(5258 files)、`npm run check:spacetime-schema`(92 tables)、`npm run check:generated-bindings`、`cargo fmt --all -- --check`、`git diff --check`。`.env` 与 `.junie` 是你工作区里既有的未跟踪 / 未提交改动,我没有碰、也不会提交。 - `cargo test -p api-server` 有一个与本轮改动无关的既有失败:`external_mcp::semantic::tests::semantic_catalog_adds_fifteen_tools_without_replacing_legacy_tools`(断言 `TOOLS.len() == 15`)。本轮提交均未触碰 `external_mcp`,该失败在合并基线 `dfa92607b` 上即存在。 ### 第 21 项追问:`export_database_migration_to_file` 是谁写的、为什么、本 PR 用了吗? - 作者 / 时间:`kdletters`,2026-04-27 14:54,提交 `9a79494c6 feat: add spacetimedb json migration tooling`。 - 用途:整库 JSON 导出 / 导入的手工工具,配套 `scripts/spacetime-export-migration-json.mjs` / `scripts/spacetime-import-migration-json.mjs` / `scripts/spacetime-migration-common.mjs`,procedure 侧就是 `migration.rs` 的 `export_database_migration_to_file` / `import_database_migration_*`。`package.json` 与 `docs/` 都没有引用它,属于按需手工执行、未接入 CI / npm scripts。 - 本 PR 用了吗:**没有**。`git log dfa92607b..HEAD -- server-rs/crates/spacetime-module/src/migration.rs` 为空,本分支提交没有碰 `migration.rs`。 - 那第 21 项为什么提它:第 21 项说的是「若用旧版导出的迁移包回灌新 schema 的库,会因新列缺回填而 `missing field`」,是**假设将来要用**该工具,不是本 PR 正在用。你已决定不做迁移,故不补。 - 补充:会员重构本来在 `21f69ef13`(王德宇,2026-10-03 17:11)给 `migration.rs` 补过 `profile_membership` / `profile_recharge_order` 的旧包默认值,但 33 分钟后被 `0c93256b8`(王德宇,2026-10-03 17:44)整条 revert,revert 只写了 `This reverts commit ...`,没有记原因。所以当前基线是「有迁移工具、但没有会员列回填」的状态。 ### 第 4 项追问:非法 token 从哪来? - 改动的 3 个 DTO 字段:`AdminUpsertProfileMembershipPlanRequest.plan` / `.model_access`(`POST /admin/api/profile/membership-plans`)、`ProfileMembershipUpgradeQuoteRequest.target_plan`(`POST /api/profile/membership/upgrade-quote`)。 - 正常前端不会产生非法 token:`AdminMembershipPlanPage` 的档位 `<select>` 选项来自 `membershipPlanLabels` 固定映射,模型权限来自 `modelAccessOptions`(`basic` / `full`),TS 类型是 `ProfileMembershipPlan` / `ProfileMembershipModelAccess` union。 - 会触发 422 的来源:绕过前端、直接向该接口发 JSON 的调用方——例如手写 curl / Postman / 脚本、联调时的旧客户端、或将来某个没复用 union 的新调用方,把 `plan` 写成 `"proo"` / `"Plus"` / `"full_access"` 之类。 - 行为对比:改之前 `plan` 是 `String`,handler 里 `parse_runtime_profile_membership_plan` 解析失败返回 **400**;改之后 serde 在反序列化阶段拒绝,Axum `Json` 提取器返回 **422**(`model_access` 改之前甚至不校验、直接存任意字符串)。 - 结论:422 只在「契约外的输入」出现,合法 token 一律不受影响;如果你希望这类非法 token 继续返回 400 而不是 422,我可以加自定义反序列化 / 提取器把 `JsonRejection` 归一成 400。 - [x] 1. `apps/admin-web/src/app/adminRoutes.ts:57` —— 会员档位未编入桌面侧边栏分组 - 现状:`adminRoutes` 已注册 `membership-plans`,后端 `ADMIN_TAB_PERMISSIONS` 也包含它;但 `adminNavGroups` 的 `billing` 分组里没有它。 - 问题:桌面侧边栏由 `getAdminNavGroups(routes)` 按分组过滤生成,未编组的路由会被丢弃,桌面端看不到「会员档位」入口(只能走移动底栏或手输 hash)。 - 修法:把 `membership-plans` 加入 `billing` 分组。 - 结果:已修复并提交 `48b688095`。 - [x] 2. `apps/admin-web/src/pages/AdminRechargeProductPage.tsx:133-135` —— 价格分/基础泥点未校验 - 现状:`handleSave` 用 `parsePositiveIntegerOrZero` 解析,空串/非法输入落成 `0`;提交按钮只在 `productId`/`title` 为空时禁用,输入框即使 `type="number" min="1"`,清空后原生校验也不拦截。 - 问题:运营清空「价格分」或「基础泥点」会静默保存 0 价 / 0 泥点商品。 - 修法:改用严格 `parsePositiveInteger`,解析为 `null` 时先提示表单错误并 `return`(在 `confirmWrite` 之前)。 - 结果:已修复并提交 `5a776f61c`。 - [x] 3. `apps/admin-web/src/pages/AdminRechargeProductPage.tsx:32-36` —— 默认 SKU 与定价比例硬编码 - 现状:默认 SKU(`points_60` / 600 分 / 60 泥点)与「¥1 = 10 泥点」同时写在顶部常量、页面 description 和注释里。 - 问题:定价口径变更时多处容易漂移。 - 修法(按你的意见:只删文案,不抽共享配置):删除页面 description 和注释里的「¥1 = 10 泥点」,并同步清掉公共充值弹层与测试里同口径的注释;默认 SKU 常量保留。 - 结果:已修复并提交 `985b8718b`。 - [x] 4. `apps/admin-web/src/pages/pageUtils.ts:66-68` —— 无调用方的重复 helper - 现状:`parsePositiveIntegerOrZero` 与 `parseNonNegativeIntegerOrZero` 对 `0`/非法输入行为一致。 - 问题:`parseNonNegativeIntegerOrZero` 全仓无调用点,属死代码且与另一个语义重复;严格版 `parseNonNegativeInteger` 仍在 `AdminProfileWalletConfigPage` 使用。 - 修法:删除 `parseNonNegativeIntegerOrZero`。 - 结果:已修复并提交 `6e419d0d8`。 - [x] 5. `apps/admin-web/src/pages/AdminMembershipPlanPage.tsx:397` —— 未知档位 code 渲染为空白 - 现状:列表和「最近保存」直接 `membershipPlanLabels[plan]`。 - 问题:后端返回未知 plan code 时得到 `undefined`,React 渲染成空白。 - 修法:改用 `formatMembershipPlan`(未知值回退原 code);表单选项仍复用 `membershipPlanLabels`,保留导入。 - 结果:已修复并提交 `0a6c70a64`。 - [x] 6. `scripts/admin-web-fake-api.mjs:809-810` —— 假数据残留已退役字段 - 现状:fake 充值商品对象仍返回 `tier` / `membershipPeriodPoints` / `membershipPeriodDays` / `membershipQueueLimit` / `membershipDiscountBps`。 - 问题:这些字段已从 Rust 契约 `ProfileRechargeProductConfigAdminResponse` 与 `adminApiTypes.ts` 移除,假数据会掩盖前后端漂移。 - 修法:删除这 5 个字段(`bonusPoints` / `durationDays` 仍是契约内退役列,保留为 0)。 - 结果:已修复并提交 `589a2623a`。 - [x] 7. `apps/admin-web/src/components/AdminUserDetailDialog.tsx:544` —— 会员状态直接渲染后端 token - 现状:`plan` / `cycleKind` 已本地化,但 `status` 仍是 `normal` / `active` 原样渲染。 - 问题:把内部 token 暴露给运营人员。 - 修法:`membershipDisplay` 新增 `formatMembershipStatus`(`active`→生效中、`normal`→非会员,未知原样返回),此处复用。 - 结果:已修复并提交 `ff2bd5b57`。 - 备注:同一 finding 还建议「非会员时收敛 `cycle_index/cycle_count = 1/1` 的账期展示」。我未改:后端注释明确保留过期行的周期信息用于展示「已失效套餐」,如何收敛需要单独定展示规则,属可选项。 - [ ] 8. `apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx:157` —— revision 未变时桶不同步 - 现状:`applyCatalog` 只在 `revision` 变化(或非有限)时才 `setModels` / `setUnavailableModels` / `setDefaultModelId`;但下方回退 notice 用的是当次新取的 `catalogUnavailableModels`。 - 问题:后端 `revision` 是**目录级**版本,而 `models` / `unavailableModels` 会按会员 `access` 过滤(见 `api-server/src/llm/mod.rs::public_model_catalog`)。会员升级后 revision 不变但两个桶会变,此时下拉仍是旧桶、notice 却按新数据算,界面与提示可能分叉。 - 注意:`apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx` 有明确用例 `keeps the applied catalog when the revision is unchanged`,把「revision 不变就不更新界面」当作既定行为断言。评审建议的「每次成功都同步桶」会与该用例冲突。 - 建议:改成按「revision + 两个桶内容」的指纹判断(同时修复档位变化与避免无变化重渲染),并同步更新该测试;或明确接受 revision 是唯一刷新锚点。留给你决定。 - [x] 9. `packages/shared/src/components/PlatformProfileRechargeModal/index.tsx:395-397` —— 缺当前档位被误判为新客 - 现状:`!isActive || !membership || !currentPlan` 统一返回「开通会员 / purchase」。 - 问题:有效会员但目录里查不到当前档位(例如档位被下架/删除)时会被当成新客,对所有档位(含更低档与当前档)开放购买,可能提交重复/降档订单。后端目前返回全表(含下架行),所以是防御性边界。 - 修法:`!currentPlan` 时返回禁用(`套餐信息缺失`),不再退化为新购。 - 结果:已修复并提交 `5d2de88a4`。 - [x] 10. `packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx:232` —— 「可用总额」与工具文档口径不一致 - 现状:钱包入口、充值弹层等处把三池合计展示为「可用总额」;`packages/shared/src/utils/mudPoints.ts` 的文档却说 `totalPoints`「只用于展示,不代表『可用额度』」。 - 问题:每日免费/月度/永久三池的重置与过期语义不同,把原始合计无条件叫「可用总额」可能误导用户。 - 修法(按你的意见:钱包入口、充值弹层展示「可用总额」是对的):改工具文档,不再声明合计「不代表可用额度」;`totalPoints` 注释改为「前端统一以『可用总额』展示」,与各展示点一致。 - 结果:已修复并提交 `af419e82c`。 - [x] 11. `server-rs/crates/api-server/src/runtime_profile.rs:1481-1484` —— 遗留后台拒绝由 400 变 502 - 现状:`map_runtime_profile_client_error` 只认 `ProcedureRejected` / `Runtime`,其余 `SpacetimeClientError::Procedure(String)` 一律 502。 - 问题:`bd06e4269` 删除中文文案匹配后,`admin_disable_profile_redeem_code` 这类仍只带裸 `error_message` 的后台兑换码结果(如 `兑换码不存在`)从原来的 400 domain 拒绝变成 502。 - 修法(按你的意见:业务拒绝必须带 typed code,而不是靠「裸字符串落 502」):`module-runtime` 新增 `RuntimeProfileRedeemCodeAdminErrorCode`(`not_found` / `invalid_field` / `allowed_users_required`),后台兑换码增改与列表 procedure 结果末尾追加 `error_code`;module 侧在拒绝点赋码,`spacetime-client` 后台 mapper 有码走 `ProcedureRejected`,api-server 由此返回 400。无码的裸字符串仍 fail-closed 到 502——这一条本身是对的(无法区分业务与基础设施),问题只在后台拒绝此前没有码。 - 结果:已修复并提交 `4f3faa04b`(含重新生成 SpacetimeDB bindings、client/api-server 单测、设计文档 §5.8 与决策日志)。 - [x] 12. `server-rs/crates/module-runtime/src/agc_model_access.rs:27-33` —— 未知 access 让整份目录解析失败 - 现状:`AgcModelAccess` 只有 `Basic` / `Full`,`AgcModel` 是 `deny_unknown_fields`,`access` 用 `#[serde(default)]`;只有缺字段才失败开放。 - 问题:`parse_catalog` 把解析失败当「未初始化」,启动期会从上游重建目录,把人工标注的 `Full` 静默重置为 `Basic`;将来新增档位时滚动发布期间会抖动。 - 修法:让未知取值也失败开放到 `Basic`。**评审给的补丁不能编译**——`#[serde(other)]` 必须放在最后一个变体。我按等价语义把 `Full` 提到前面、`Basic` 放最后并加 `#[serde(other)]`,并补未知 `access` 回退 `Basic` 的单测。 - 结果:已修复并提交 `0c9a16ba1`。 - [x] 13. `server-rs/crates/module-runtime/src/membership/cycle.rs:28-34` —— 年份越界未先收口 - 现状:`beijing_local_micros` 先用 `days_in_month` / `days_from_civil` 做历法换算,再靠 `checked_*` 兜底;年份没有范围检查。 - 问题:`days_from_civil` 里 `era * 146_097` 是 **i32** 运算,|year| 超过约 590 万即溢出(debug panic、release 回绕成看似合法的值),违反「越界返回 `None`」契约。原测试只覆盖到 `1_000_000`,低于阈值。 - 修法:进入历法换算前把 year 收口到 i64 微秒可表示范围(±292_278),并补 `10_000_000` 级用例。 - 结果:已修复并提交 `8bc48e9e6`。 - [x] 14. `server-rs/crates/shared-contracts/src/runtime.rs:437-438` —— 客户契约 `status` 仍是 `String` - 现状:`ProfileMembershipResponse.status` 是 `String`,而 `plan` / `cycle_kind` 已收紧为 token enum;`ProfileMembershipStatusToken` 已存在且 admin payload 在用,module-runtime 也已有 `From<RuntimeProfileMembershipStatus>`。 - 问题:客户契约可表示任意/非法状态,且与 admin payload 类型分叉。 - 修法(按你的意见:use enum):`ProfileMembershipResponse.status` 改为 `ProfileMembershipStatusToken`,BFF 用现有 `From<RuntimeProfileMembershipStatus>` 转换;wire 值不变(`normal` / `active`),前端 `ProfileMembershipStatus` 本就已是该 token 的别名。 - 结果:已修复并提交 `cc0c07223`。 - [x] 15. `server-rs/crates/shared-contracts/src/admin.rs:1727` —— access 注释措辞误导 - 现状:`AdminAgcModel.access` 与 module-runtime `AgcModel.access` 的注释写成「该模型要求的会员档位」。 - 问题:`basic` / `full` 是模型访问级别,不是会员 plan 档位(`normal`/`starter`/`plus`/`pro`/`max`),措辞会让运营误解。 - 修法:注释改为「访问级别」。 - 结果:已修复并提交 `6d9125586`。 - [x] 16. `packages/shared/src/components/PlatformProfileRechargeModal/index.tsx:56-57` —— 未选中时 ArrowLeft 落点错误 - 现状:`resolveRovingIndex` 注释明确 `currentIndex` 可为 `-1`,但 `ArrowLeft` 用 `(currentIndex - 1 + length) % length`。 - 问题:`-1` 时得到 `length - 2` 而非 `length - 1`;泥点档位在默认选中 effect 跑之前确实有一帧 `selectedProductId === null`。 - 修法:`currentIndex < 0` 时 `ArrowLeft` 返回 `length - 1`。 - 结果:已修复并提交 `f0dfddb91`。 - [x] 17. `packages/shared/src/components/PlatformProfileRechargeModal/index.tsx:909` —— 泥点档位禁用被会员下单波及 - 现状:`PlatformPointProductGrid` 用 `disabled={Boolean(submittingProductId)}` 禁用全部泥点档位。 - 问题:该 prop 现与会员下单共用,会员提交(`membership-<plan>-<cycle>`)也会禁用泥点档位,而泥点「充值」CTA 只看 `selectedPointProduct` 仍可点,出现跨页签耦合。 - 修法:仅当提交 id 命中泥点商品时才禁用整组。 - 结果:已修复并提交 `d3f02d6bc`。 - [x] 18. `server-rs/crates/module-runtime/src/agc_model_access.rs:171-175` —— 遗留无档位 resolver 与新 resolver 并存 - 现状:`AgcModelCatalog::resolve` / `resolve_requested`(只看 `enabled` + `id`)仍公开,生产调用方已迁到 `*_for(access)`,但旧方法仍可被调用。 - 问题:未来调用方可能走无档位校验的老路径,静默绕过会员检查。 - 修法(按你的意见:remove them):删除 `AgcModelCatalog::resolve` / `resolve_requested`;`validate` 内联「默认项存在且启用」检查;相关单测改用 `resolve_requested_for`。 - 结果:已修复并提交 `7579213be`。 - [x] 19. `server-rs/crates/shared-contracts/src/llm_catalog.rs:147-149` —— `protocol` 默认值不感知 `agent_mode` - 现状:`LlmModelSummary` / `LlmUnavailableModel.protocol` 用 `#[serde(default)]`,缺省落到 `AgcModelProtocol::default()` = `openai_responses`,与 `agent_mode` 无关;而 `Cc` 的默认协议实际是 `anthropic`。 - 问题:遗留 payload `agentMode: "cc"` 且缺 `protocol` 会反序列化成不合法组合 `Cc + OpenAiResponses`,与 `AgcAgentMode::default_protocol()` 矛盾。 - 修法(按你的意见:custom the default value):两个 DTO 改为经私有 wire 结构体反序列化,缺 `protocol` 时取 `agent_mode.default_protocol()`;显式 `protocol` 优先,`anthropic` 别名仍映射 `Cc`。同步重新生成绑定注释。 - 结果:已修复并提交 `c7150b590`。 - [x] 20. `server-rs/crates/shared-contracts/src/llm_catalog.rs:24-25` —— 前向兼容策略不一致 - 现状:同一响应里 `LlmModelUnavailableReason` 有 `#[serde(other)] Unknown` 兜底,而 `AgcAgentMode` / `AgcModelProtocol` 对未知字符串 fail closed;三者都在 `GET /api/llm/models` 同一响应,Rust 客户端整体反序列化。 - 问题:后端将来新增 agent mode/协议时,旧客户端会整份响应解析失败(而非只影响该条目),与响应宣称的前向兼容目标不一致。 - 修法(按你的意见):响应 DTO 的 wire 反序列化对未知 `agentMode` / `protocol` 回退到 `Codex` / `agent_mode` 的默认协议,旧客户端不再整份失败;enum 本身不新增变体,写入/输入校验仍走严格 `parse`,因此不影响请求侧门禁。 - 结果:已修复并提交 `4bd080eff`。
k88936 added 44 commits 2026-10-03 13:34:50 +08:00
- 领域层清理:删除 RuntimeProfileMembershipTier 及旧的 tier 别名体系
- 会员目录行改名 RuntimeProfileMembershipPlanRecord,快照新增 enabled 字段
- 补差升级报价入参改为「目录表解析出的当前/目标套餐快照」
- 新增会员商品 product_id 编解码(档位 + 周期,严格解析拒绝未知 token)
- 报价与允许操作矩阵改为读取快照 rank / enabled,不再走编译期常量

Co-authored-by: Junie <junie@jetbrains.com>
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 <junie@jetbrains.com>
- 领域层新增会员档位枚举、自然月账期、目录快照与补差报价入参,删除旧 tier 相关函数
- profile_membership 表以 plan 与自然月期数取代 tier 与固定 30 天周期
- profile_recharge_order 末位追加会员权益变更快照,供人工复核退款
- profile_membership_plan 目录表成为价格与权益的唯一真相源,新增后台读写接口
- 充点商品收敛为六档且取消首充赠送,旧播种行按新规则迁移
- 充值中心改为输出会员目录,不再输出会员商品行与权益矩阵
- 重新生成 SpacetimeDB 绑定并同步客户端映射与 facade

Co-authored-by: Junie <junie@jetbrains.com>
- 余额响应改为每日免费 / 月度 / 永久三池并列,不再暴露总额与会员限时泥点
- 会员响应与充值中心以 plan 取代 tier,充值中心改为输出会员目录
- 微信虚拟支付按目录档位校验会员商品,不再依赖会员商品配置行
- 同步 shared-contracts 测试夹具到新的三池与 plan 口径

Co-authored-by: Junie <junie@jetbrains.com>
新增 POST /api/profile/membership/upgrade-quote:只读报价,下单时后端用同一纯函数重算

新增后台 /admin/api/profile/membership-plans 的读取与按档位改价改权益接口

订单与管理端契约补上会员权益变更快照(变更前后档位、周期、补点、有效期与价格拆分)

报价结果统一走「模块侧快照 + application 记录侧投影」两段式,与既有 DTO 约定一致

Co-authored-by: Junie <junie@jetbrains.com>
- 给 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 <junie@jetbrains.com>
- createProfileWalletStore 的余额状态改为三池视图,去掉「响应只有 walletBalance 时兜底」的旧分支,缺少 mudPointBalance 一律算读取失败
- 泥点明细面板并列展示永久泥点 / 月度泥点 / 每日免费泥点,月度行带北京时间刷新时刻;「不限时泥点」标签改为「永久泥点」
- 充值弹层当前余额改用三池合计,不再读 walletBalance
- 三池与首充赠送相关的 store、明细面板、充值弹层测试同步更新

Co-authored-by: Junie <junie@jetbrains.com>
- 平台入口与钱包 store 不再从充值中心响应读 walletBalance,合计改为按三池派生
- AGC 账户钱包同步:只透传 mudPointBalance,删掉 RechargeContent 的 walletBalance 排除项
- 删除已无生产调用方的 applyLegacyWalletBalanceSnapshot 与对应测试;明细读取失败不再伪造「只有总额」的中间态
- 图片编辑器与平台入口测试 fixture 改为三池形状,钱包面板断言改用「永久泥点 / 月度泥点」

Co-authored-by: Junie <junie@jetbrains.com>
- 充值商品页改为纯泥点商品:去掉会员档位与 membership_* 字段,固定 kind=points,首充赠送默认 0
- 新增会员档位页:按档位枚举改月价 / 年价 / 每期泥点 / 模型权限 / 并发上限 / 可购买 / 排序,rank 只读
- adminApiTypes 用档位枚举替换旧 tier 类型并补会员档位请求响应类型,客户端补 listProfileMembershipPlans / upsertProfileMembershipPlan
- 注册 #membership-plans 路由与侧边栏图标

Co-authored-by: Junie <junie@jetbrains.com>
- 【技术设计】泥点三池与会员计费后端设计:余额投影改为扁平三池标量、报价请求不收周期、会员订单 product_id 编码档位与周期、充值中心改 membershipPlans,新增后台接口与 ts-rs 契约生成小节
- §10.2 实施状态由「待落地清单」改写为「已落地 + 明确不做」,注明永久余数仍是推导实现
- docs/README 索引标注两份文档已落地;decision-log 追加 2026-10-02 状态与实现期补充决断

Co-authored-by: Junie <junie@jetbrains.com>
- .prettierignore 排除 packages/shared/src/contracts/generated/:prettier 重排会让 check:generated-bindings 的「重新生成逐字节比对」永远失败
- 按 ts-rs 实际输出重新生成 ProfileMudPointBalanceResponse.ts(含字段文档注释)

Co-authored-by: Junie <junie@jetbrains.com>
weekday_from_date_key 先按 7 取模再加偏移,消除 date_key 接近 i64::MAX 时的中间加法溢出

Co-authored-by: Junie <junie@jetbrains.com>
civil_from_days 用 saturating_add 计算日序号偏移,消除 date_key 接近 i64::MAX 时的加法溢出

Co-authored-by: Junie <junie@jetbrains.com>
RuntimeProfileMembershipPlan 文档改为说明排序由目录 rank 决定、枚举不实现 Ord,避免误以为变体顺序是排序契约

Co-authored-by: Junie <junie@jetbrains.com>
beijing_local_micros 文档由「本地日期不存在返回原值」改为「返回 0」,与实际实现一致

Co-authored-by: Junie <junie@jetbrains.com>
beijing_local_micros 增加 time_of_day_micros 越界即返回 0 的校验,避免越界时刻被解释成另一天

Co-authored-by: Junie <junie@jetbrains.com>
在 catalog 文档写明升级补差依赖的单调性不变量,并新增回归测试断言 rank 越高月价、年价、每期泥点严格递增

Co-authored-by: Junie <junie@jetbrains.com>
- lib.rs 移除 pub use civil_calendar::*,公历基元只保留 civil_calendar:: 命名空间

- lib.rs 移除 pub use membership::*,改为只重导出对外的会员契约符号

- 未改动 spacetime-module / spacetime-client / api-server,现有引用保持可用

Co-authored-by: Junie <junie@jetbrains.com>
- 新增 RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput 及 wire token

- 报价前校验期号范围与账期窗口,避免 saturating_sub 静默夹取

- 新增 invalid_numeric_input_is_rejected 回归测试

Co-authored-by: Junie <junie@jetbrains.com>
- 在领域数据结构 §3 记录 quote_runtime_profile_membership_upgrade 的防御式自检与 invalid_quote_input 拒绝

Co-authored-by: Junie <junie@jetbrains.com>
runtime_profile_membership_plan_rank 返回 Option<u8>,目录缺行时返回 None

去掉 unwrap_or(0),不再把目录缺失伪装成 Normal 而误报 NotUpgrade

plan_rank_follows_catalog_order 测试断言同步改为 Some(..)

Co-authored-by: Junie <junie@jetbrains.com>
ADR §3 更新 runtime_profile_membership_plan_rank 签名为 Option<u8>

说明目录缺行时返回 None,不再用 unwrap_or(0) 伪装成 Normal

Co-authored-by: Junie <junie@jetbrains.com>
runtime_profile_membership_plan_catalog 加 TODO,说明每次重建只有 5 行、开销可忽略

按作者意见标记 wont fix,暂不改 LazyLock / &'static 缓存

Co-authored-by: Junie <junie@jetbrains.com>
- 删除 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 <junie@jetbrains.com>
删除 .prettierrc.json 中指向 packages/shared/src/contracts/generated/**/*.ts 的 overrides
该目录已加入 .prettierignore,覆盖项不再生效,只会误导维护者

Co-authored-by: Junie <junie@jetbrains.com>
AdminApp.tsx 的 clearSession 与 handleLogin 中补上 setMembershipPlanResult(null)
与其余三个结果状态保持一致,避免换管理员登录后展示上一会话的「最近保存」面板

Co-authored-by: Junie <junie@jetbrains.com>
月价 / 年价输入框改为 min={isPurchasable ? 1 : 0},normal 档(0 价)不再被浏览器原生校验拦住
下拉选到目录中不存在的档位时清空表单并置空 rankLabel,避免提交上一档的陈旧值

Co-authored-by: Junie <junie@jetbrains.com>
refreshProducts 过滤 response.entries,仅保留 kind === 'points'
避免历史会员商品行被渲染成 0 + 0 泥点,且保存时必然被后端拒绝

Co-authored-by: Junie <junie@jetbrains.com>
resolveProfileMudPointBalanceView 为 dailyFreeResetPoints 补 ?? 0,避免界面显示 NaN
formatBeijingDateTime 改用 hourCycle: 'h23',保证午夜稳定输出 00:00 而非 24:00

Co-authored-by: Junie <junie@jetbrains.com>
walletStore.test.ts 去掉已删除的 applyLegacyWalletBalanceSnapshot 断言
使「mudPointBalance 为权威余额」用例与当前 store 实现保持一致

Co-authored-by: Junie <junie@jetbrains.com>
可购买档位新增 period_points == 0 拒绝;不可购买档位固定使用目录种子的并发上限与模型权限
InvalidRechargeProductTier 拆为 InvalidRechargeProductKind 与 InvalidRechargeProductFields,报错不再误导运营

Co-authored-by: Junie <junie@jetbrains.com>
quote_runtime_profile_membership_upgrade 用 checked_sub 替换 saturating_sub,价格或每期泥点倒挂时返回 InvalidQuoteInput
新增 inverted_catalog_price_and_points_are_rejected 覆盖原本会算成 0 元升级的场景

Co-authored-by: Junie <junie@jetbrains.com>
resolve_default_point_product_migration 同时识别停用的「首充双倍」行(赠送为 0 或等于面额)与在售的「首充加赠」行
更新迁移单测:disabled_seed 按旧迁移后的真实状态构造,并补充「首充加赠」用例

Co-authored-by: Junie <junie@jetbrains.com>
- 设计文档 §5.6 补充用户详情会员状态、月度泥点命名与表浏览器标签
- 设计文档 §10.2 前后端清单补登订单会员快照与用户会员状态可视化
- 决策记录登记 2026-10-03 后台会员计费运维面补全决策

Co-authored-by: Junie <junie@jetbrains.com>
- shared-contracts 新增 AdminMembershipOrderChangePayload 与 AdminProfileMembershipPayload
- AdminProfileWalletPayload.membership_limited_points 改名 monthly_points
- AdminRechargeOrderEntryPayload 增加 membership_change 快照字段
- AdminUserDetailResponse 增加 membership 字段
- admin_recharge 映射会员订单快照与用户会员中心投影

Co-authored-by: Junie <junie@jetbrains.com>
- 新增 config/membershipDisplay.ts 收敛档位与账期展示并供三处复用
- 用户详情对话框新增会员区块,钱包月度池改名「月度泥点」
- 充值订单列表与退款弹窗渲染会员变更快照
- 通用表浏览器补 profile_membership_plan 标签
- 补齐管理端类型、用例与本地假数据

Co-authored-by: Junie <junie@jetbrains.com>
- PlatformProfileRechargeModal 重写为「会员订阅 / 泥点充值」双 Tab,含三池概览、套餐卡、周期切换、会员状态、套餐对比表与确认订单页
- 新增 membershipCopy 收敛数量估算、推荐标签、模型权限与并发展示文案,价格与权益仍读后端目录
- 新增 membershipProduct 提供由档位与周期拼出的会员 productId,避免与组件混导出
- PlatformMudPointWalletEntry 入口改为「可用总额 + 行内月度/永久 + 会员与泥点」,使用详情与兑换码保留为次级入口
- 同步改写共享弹层测试,覆盖双 Tab、升级报价与确认页

Co-authored-by: Junie <junie@jetbrains.com>
- platformProfileClient 新增会员升级报价接口调用
- usePlatformProfileCenterController 增加报价状态与会员下单入口
- PlatformEntryActiveFlowShell 与 ImageCanvasEditorView 向共享弹层透传报价与会员下单回调
- 同步修正平台入口与图片编辑器顶栏受入口文案变化影响的测试断言

Co-authored-by: Junie <junie@jetbrains.com>
- 新增 Tauri 命令 read_profile_membership_upgrade_quote 并注册到桌面端
- accountHost 新增会员升级报价调用
- useAccountWallet 与 AccountWallet 接通报价、会员下单与共享弹层新 props
- 补充 accountHost 报价调用测试

Co-authored-by: Junie <junie@jetbrains.com>
- 新增技术设计文档,记录拷问结论、现状核实、改动清单与验收判据
- docs/README.md 登记该技术设计

Co-authored-by: Junie <junie@jetbrains.com>
- 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 <junie@jetbrains.com>
- 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 <junie@jetbrains.com>
- 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 <junie@jetbrains.com>
refactor(会员): 升级报价改为只读,不再刷新账期与钱包
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
90e520fdba
- profile.rs:build_profile_membership_upgrade_quote_record 不再调用 refresh_profile_wallet_expiring_points,改用只读投射 project_profile_membership_cycle_at 把账期推进到当前时刻
- profile.rs:报价接口不再写库 / 发账本,不会提前消耗用户的懒刷新,同时保持与建单、结算同口径

Co-authored-by: Junie <junie@jetbrains.com>
k88936 added 1 commit 2026-10-03 13:36:27 +08:00
Merge remote-tracking branch 'origin/master' into feat/pricing-plan
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m28s
Project CI / Backend tests (pull_request) Failing after 53s
Project CI / Native shell tests (pull_request) Failing after 1m33s
Project CI / Frontend tests (pull_request) Failing after 1m2s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m43s
Project CI / AI game creator shell web tests (pull_request) Failing after 45s
Project CI / Repository checks (pull_request) Failing after 1m3s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m24s
14ddf09f30
k88936 added 54 commits 2026-10-03 20:33:03 +08:00
- adminApiTypes.ts:AdminMembershipOrderChangePayload.changeKind 去掉多余的 string 分支,非法取值改由编译期暴露

Co-authored-by: Junie <junie@jetbrains.com>
- membershipCopy.ts:resolveMembershipConcurrencyLabel / resolveMembershipConcurrencyShortLabel 改为调用 isUnlimitedConcurrency,去掉重复的 128 判定

Co-authored-by: Junie <junie@jetbrains.com>
- AdminMembershipPlanPage.tsx:parseInteger 改为 Number + Number.isSafeInteger,避免 "1e2" / "1.9" 被 parseInt 静默截断后保存错误价格

Co-authored-by: Junie <junie@jetbrains.com>
- PlatformProfileRechargeModal/index.tsx:去掉套餐卡外层 div 的 onClick,点击 CTA 时不再因事件冒泡触发两次 openMembershipCheckout

Co-authored-by: Junie <junie@jetbrains.com>
- module-runtime/membership/upgrade.rs:ceil_div 增加 debug_assert!(denominator != 0),零分母在测试里立即失败,不再被静默算成 0 元

Co-authored-by: Junie <junie@jetbrains.com>
- scripts/admin-web-fake-api.mjs:新增 /admin/api/profile/membership-plans 的 GET 列表与 POST 回显,避免页面 response.entries 为 undefined

Co-authored-by: Junie <junie@jetbrains.com>
- useAccountWallet.ts:requestMembershipQuote 记录请求令牌,只有最新请求可以写入报价、错误与 loading

Co-authored-by: Junie <junie@jetbrains.com>
- usePlatformProfileCenterController.ts:requestMembershipQuote 增加 membershipQuoteRequestRef 令牌,过期响应不再覆盖新报价或提前结束 loading

Co-authored-by: Junie <junie@jetbrains.com>
- adminApiTypes.ts:AdminProfileMembershipPayload.status 由 string 收窄为 'normal' | 'active'

Co-authored-by: Junie <junie@jetbrains.com>
- AdminMembershipPlanPage.tsx:非 normal 档位要求每期泥点大于 0,不再等后端返回笼统目录错误

Co-authored-by: Junie <junie@jetbrains.com>
- module-runtime/membership/upgrade.rs:账期窗口校验由 < 改为 <=,cycle_resets_at 等于 cycle_started_at 时不再退化成「剩余比例为 0」

Co-authored-by: Junie <junie@jetbrains.com>
- scripts/admin-web-fake-api.mjs:月付用户详情 cycleIndex 由 2 改为 1,避免出现「第 2/1 期」

Co-authored-by: Junie <junie@jetbrains.com>
- useAccountWallet.ts:buyRechargeProduct 只接受 productId 与 kind,会员下单不再伪造价格等无用字段

Co-authored-by: Junie <junie@jetbrains.com>
- usePlatformProfileCenterController.ts:buyRechargeProduct 只接受 productId 与 kind,会员下单不再传 priceCents 等无用字段

Co-authored-by: Junie <junie@jetbrains.com>
- runtime_profile.rs:新增 is_runtime_profile_membership_domain_error,会员升级报价(非会员 / 重复购买 / 月年互换 / 档位下架 / 非法报价入参)与会员档位目录校验失败统一映射为 400,基础设施错误仍保持 502

Co-authored-by: Junie <junie@jetbrains.com>
- apps/ai-game-creator-shell/scripts/check-config.mjs:allowedUncalledTauriCommands 增加 read_profile_membership_upgrade_quote,node scripts/check-config.mjs 恢复通过

Co-authored-by: Junie <junie@jetbrains.com>
- AdminMembershipPlanPage.tsx:标题去空格为空时禁用保存按钮,不必等确认弹窗后才收到 MissingProductTitle 报错

Co-authored-by: Junie <junie@jetbrains.com>
- usePlatformProfileCenterController.ts:账号生命周期重置时清空 membershipQuote / loading / error 并作废未完成报价令牌,避免上一个账号的报价串到新账号

Co-authored-by: Junie <junie@jetbrains.com>
删除「常规充值 ¥1 = 10 永久泥点」的写死文案,档位价格与点数完全由后台商品目录决定,避免目录改价后文案与实际不一致。

Co-authored-by: Junie <junie@jetbrains.com>
修正 upgrade.rs 报价校验:now 必须落在 [cycle_started_at, cycle_resets_at) 内,越界按 InvalidQuoteInput 拒绝,避免旧 cycle_index 配零剩余比例多收一个完整月
同步模块头注释、校验函数文档与 InvalidQuoteInput 枚举说明
把「过期窗口只收完整月」的用例改为「过期窗口被拒绝」,保留剩余比例自身的 0 断言

Co-authored-by: Junie <junie@jetbrains.com>
新增 check_expired_membership_upgrade_settlement:补差订单在支付前会员已到期时,订单金额必须达到目标周期当前目录价才允许按新购结算,否则拒绝
新增 RuntimeProfileMembershipUpgradeRejection::MembershipExpiredBeforeSettlement 及错误码字符串
module-runtime 导出该判定函数供结算侧复用
spacetime-module 结算分支调用该判定,目录缺档位时返回明确错误
api-server 把「会员已到期,请按新购价格重新下单」映射为 400 业务错误
补充过期补差订单必须覆盖整期新购价的单元用例
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 单测调用
结算补差升级单前比对 settlement.plan_before / cycle_kind_before 与当前会员行,不一致直接拒绝「会员状态已变化,请重新下单」
避免用户先付更高档位升级单后,旧升级单落地把档位 / 周期覆盖回去,出现降档或「年付 + 一个月到期」的矛盾行
api-server 把该业务拒绝映射为 400
ProfileMembershipPlanResponse 新增 recommended 与 unlimitedConcurrency 两个派生布尔
module-runtime 新增 PROFILE_MEMBERSHIP_RECOMMENDED_PLAN 常量并导出,作为推荐档位唯一真相源
api-server 目录响应按该常量与 is_unlimited_concurrency 派生标志
前端 ProfileMembershipPlanRecord 契约补上两个字段
membershipCopy 删除硬编码的 'plus' 与 128,并发文案改读 unlimitedConcurrency
充值弹窗推荐标签改读 plan.recommended
更新充值弹窗测试夹具
新增 init 生命周期 reducer,数据库首次创建时把 profile_membership_plan 整表播种
membership_plan_row 与 membership_plan_records 去掉懒播种,只读路径不再写库
ensure_default_profile_recharge_product_config 不再顺带初始化会员目录
存量数据库此前已由懒播种写入目录行,新建数据库由 init reducer 播种
新增只读会员快照 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 改用只读会员快照,打开详情不再改用户钱包与会员账期
- 后端: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 决策记录
- 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 路径。
- profile.rs 新增 converge_default_profile_recharge_product_config,在存量库读取路径补齐缺失的 points_1280 / points_3280 档位。

- 同一步骤删除已退役的 membership 类型充点商品配置行,避免冲突迁移把旧 member_* 行原样带回后残留。

- 存量库充值商品目录因此幂等收敛到当前六档点卡,不再停留在旧四档 + 四行会员商品。
- AdminRechargeOrderPage 的会员变更摘要仅在存在账期时才追加 "/ 月付|年付",避免输出 “新购 Pro / ”。
- PlatformProfileRechargeModal 的 quoteForTarget 同时校验 targetPlan 与 cycleKind,账期不一致的陈旧报价按“暂无报价”处理,避免展示错误金额并解锁下单。
- 抽出 resolveMembershipQuoteErrorState,用提前返回替代嵌套三元,消除升级报价错误 / 加载 / 空三态的深层条件表达式。
- PlatformRechargeTabNav 与 PlatformPointProductGrid 在方向键 / Home / End 改变选中项后,用 ref 把焦点移到新按钮,保持 roving tabindex 焦点与选中一致。
- 为点卡默认选中索引 2 的取值补上中文注释,说明其只影响默认选中项、不改变计费。
- useAccountWallet.buyRechargeProduct 入参收窄为仅 productId,会员结算调用同步删除未被消费的 kind: 'membership'。
- quote_runtime_profile_membership_upgrade 先跑账期变更 / 档位合法性校验,再跑期号与窗口数值校验,使年付转月付等高期号场景返回 CycleKindChangeUnsupported 而非 InvalidQuoteInput。
- 补充年付转月付的回归断言,覆盖目标账期 period 数小于当前期号的拒绝路径。
- validate_runtime_profile_membership_upgrade_quote_input 增加 expires_at >= cycle_resets_at 校验,避免回显早于下次刷新日的到期时间。
- 补充到期时刻早于本期刷新时刻的回归断言,并更新函数文档说明。
- validate_runtime_profile_membership_plan_row_against_catalog 文档注明 model_access 与 concurrent_job_limit 本期仅落字段供展示,不参与随 rank 的单调校验。
- ADMIN_TAB_PERMISSIONS 增加 membership-plans 并把数组长度改为 22,使非 owner 管理员可被授予会员档位页权限,修复该页接口对普通管理员恒 403。
- every_assignable_admin_tab_has_a_backend_route 增加断言:路由使用的每个 Tab 必须登记在 ADMIN_TAB_PERMISSIONS。
- 移除 RuntimeProfileFieldError::InvalidRechargeProductDuration 及其 Display 分支;会员商品改走 InvalidRechargeProductKind 后该变体已无构造点。
- is_runtime_profile_membership_domain_error 增加 "会员商品 product_id 无法解析: " 与 "充值商品类型无效:会员商品已迁移到会员档位目录" 前缀匹配,避免这两类可读业务拒绝落到 502。
- 新增 membership_business_rejections_map_to_bad_request 覆盖升级拒绝、商品 id 解析失败与旧会员商品类型三条路径。
This reverts commit af0721fb78.
This reverts commit 21f69ef13b.
- ProfileMembershipPlanAdminListResponse 增加 unlimitedConcurrencyThreshold,会员档位列表接口下发 MEMBERSHIP_UNLIMITED_CONCURRENCY。
- admin-web 的档位响应补上 unlimitedConcurrency,列表“不设上限”展示改用该标志,表单提示与校验改用后端阈值,删除页面硬编码的 128。
- 本地假后台同步补 unlimitedConcurrency 与 unlimitedConcurrencyThreshold。
- 假后台读取 POST 请求体,把提交字段覆盖到对应档位行再回显,保存非 starter 档不再把表单与列表重置成 starter。
- 抽出 UNLIMITED_CONCURRENCY_THRESHOLD 常量,列表与回显共用同一哨兵。
- 钱包入口与充值按钮断言改用 `泥点账户`、`会员与泥点`。
- 充值弹窗断言改用当前 `会员与泥点` / `确认订单` / `可用总额 … 泥点`,并补上切换到泥点充值 Tab、在确认订单弹窗内点击的步骤。
- pageUtils 新增严格的 parsePositiveInteger / parseNonNegativeInteger 与落库用 parsePositiveIntegerOrZero / parseNonNegativeIntegerOrZero。
- 账号配置、会员档位、充值商品三页删除各自的同名本地解析器,统一走共享实现,消除同名不同语义的校验漂移。
- 解析从 parseInt 改为 Number + isSafeInteger,'12abc' 一类脏输入不再被截断成 12。
- 抽出 DEFAULT_RECHARGE_PRODUCT_FORM 并让新建表单初始化与 resetForm 共用。
- 过滤后没有泥点商品时调用 resetForm,避免表单残留上一次选中或已删除商品的旧值被误保存。
- domain.rs 恢复已退役的 RuntimeProfileMembershipTier 枚举,仅供存量 tier 列兼容。

- profile.rs 将 profile_membership 的 tier / cycle_period_days 还原到原始位置,新字段 plan / cycle_index / cycle_count / cycle_kind 末位追加。

- profile.rs 恢复 profile_recharge_product_config 的 tier 与 membership_* 兼容列,运行时不读不写、写入统一用固定默认值。

- 重新生成 module_bindings;schema guard 无需 allow-breaking 即通过,发布不再触发导出 / 清库 / 回灌。

- 数据契约文档同步标注退役列与六档泥点商品。
- profile.rs 在 membership_plan_row / membership_plan_records 恢复懒播种,避免增量发布不触发 init reducer 导致 profile_membership_plan 为空。

- 下单校验、升级报价、充值中心读取目录前都会先补种代码内种子目录。
- beijing_local_micros 返回 Option<i64>,不再用 0 兼作非法哨兵,合法 epoch 0 可被区分。
- advance_beijing_months_clamped 对夹取后的合法输入 expect,相关测试助手同步更新。
- 补 beijing_local_micros 区分合法 epoch 与非法输入的用例。
- RuntimeProfileMembershipUpgradeQuoteSnapshot 的 current_plan / target_plan / cycle_kind 由 String 改为 RuntimeProfileMembershipPlan / RuntimeProfileMembershipCycleKind,把非法值挡在类型边界外。
- 模块构造点直接写入枚举;记录侧在 build_runtime_profile_membership_upgrade_quote_record 用 as_str() 转回对外 token,HTTP 契约不变。
- spacetime-client 映射改用 plan / cycle_kind 的 back 映射还原枚举;重新生成 module_bindings。
- 冻结的新购单下单时用户无有效会员,若支付前已通过其他订单获得有效会员,结算会按 user_id 删除重插,覆盖更新 / 更长的会员。
- 在 apply_profile_membership_purchase 中当 change_kind 为 Purchase 且 active_row 存在时返回「会员状态已变化,请重新下单」,与升级分支守卫一致。
- 历史无快照的重算路径只会在 active_row 为空时产出 Purchase,不受影响。
fix(会员结算): 升级单冻结账期身份并在支付时校验
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
5826b2e56c
- ProfileMembershipOrderSnapshot / ProfileMembershipSettlement 增加 cycle_index_before 与 cycle_resets_at_before_micros,建单时写入订单已有的会员快照列,不新增 schema 字段。
- 支付结算前比对冻结期号与刷新时刻,账期已推进则返回「会员状态已变化,请重新下单」,避免按旧周期剩余比例算出的补点叠加到已重置的新周期。
- 抽出 frozen_membership_cycle_matches 纯函数并补单测;历史无快照订单冻结值为 None,跳过校验。
k88936 added 1 commit 2026-10-03 20:33:12 +08:00
Merge remote-tracking branch 'origin/master' into feat/pricing-plan
Project CI / Backend tests (pull_request) Failing after 1m51s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m10s
Project CI / Frontend tests (pull_request) Failing after 1m31s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m59s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m50s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m38s
Project CI / Repository checks (pull_request) Failing after 4m27s
Project CI / Native shell tests (pull_request) Successful in 7m43s
dfa92607b4
# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
#	server-rs/crates/module-runtime/src/lib.rs
k88936 added 22 commits 2026-10-04 13:31:55 +08:00
- parsePositiveInteger / parseNonNegativeInteger 改用 ^\d+$ 校验原始字符串
- 拒绝 1.0、1e2、0x10、+5 等 Number 会隐式转换的写法,与'严格整数'注释一致
- 新增 parseSafeIntegerText 统一十进制校验,四个导出函数改为薄包装
- 注明 parsePositiveIntegerOrZero / parseNonNegativeIntegerOrZero 语义等价
- 说明缺失档位会让倒挂静默通过,要求新增调用方传全量目录表
- 注明与升级报价显式拒绝 cycle_index=0 的差异及兼容存量行的原因
- 用 if/else 预计算 actionButtonLabel,明确 禁用/提交中/默认 的优先级
- 页签列表与泥点档位网格共用同一个 roving index 解析函数
- 用 switch 取代深层嵌套三元,Home/End/左右键语义保持不变
- 新增 amountPending:升级报价缺失时金额区显示占位符,CTA 只显示「补差升级」
- 补一条回归测试覆盖报价加载中的确认页
- 微信支付是纯展示的单一选项,不可交互的 radio 会误导读屏
- 改为普通文本容器,视觉样式不变
- 在注释里给出 limits.rs 与 MEMBERSHIP_UNLIMITED_CONCURRENCY 定义,便于后端改动时同步
- 说明极端日序号会饱和并给出无意义日期,要求调用方保证输入范围
- 年份导致微秒溢出时返回 None,符合函数文档,不再饱和成看似合法的值
- 补测试覆盖 i64 溢出年份返回 None
- upsert 解析 rank 失败改为显式报错,避免与 Normal 占位同档
- 与 module-runtime 用 Option 暴露缺失档位的契约保持一致
- build_wechat_virtual_pay_params 的档位校验补齐 enabled 与 is_purchasable
- 与 resolve_enabled_profile_recharge_order_product 的可售判定对齐,拒绝 Normal 占位与下架档位
- advance_profile_membership_cycle_to 只在 cycle_resets_at 缺失时判定需要初始化写库
- cycle_index == 0(新列默认值)只补写期号水位,不重置 cycle_granted/remaining_points
- 补测试覆盖 cycle_index=0 且账期窗口已存在时保留本期余额
- profile_recharge_order.membership_granted_points_delta 列由 i64 改为 u64
- RuntimeProfileMembershipOrderChangeSnapshot 与 AdminMembershipOrderChangePayload 同步改 u64
- 删除 snapshot/order 之间的 as i64 与 try_from 兜底转换
- 重生成 SpacetimeDB 绑定:profile_recharge_order、订单变更快照
- 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 绑定:订单变更快照字段改为枚举
- AdminUpsertProfileMembershipPlanRequest.plan/model_access 由 String 改为 token 枚举
- 未登记的档位 / 权限 token 在反序列化阶段直接拒绝,后台不能再持久化任意字符串
- admin handler 去掉字符串解析分支,改为 From 转换
- 删除仅用于手写解析的 parse_runtime_profile_membership_* 辅助函数
- membership_granted_points_delta 列类型文档由 i64 更新为 u64
- 补充 shared-contracts 新增四个会员 wire token 枚举及其与模块枚举的映射说明
- 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 与决策日志
- is_runtime_profile_redeem_code_domain_error 顶部注明仍靠中文文案匹配判 400
- 指明后续按 RuntimeProfileMembershipErrorCode + ProcedureRejected 的做法改造
- ensure_default_profile_membership_plan 注释说明 init 与读取 helper 共用、目录空时首次读取会写库
- init_profile_membership_plan_catalog 注释改为不覆盖「表已存在但为空」的存放量库
- 报价与建单注释去掉「不写库」的绝对说法,注明目录空时的懒播种例外
- 同步设计文档 7 / 10.2 与决策日志(播种策略定为保留懒播种 + 明示副作用)
Merge remote-tracking branch 'origin/master' into feat/pricing-plan
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
65b9821d25
# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
k88936 marked the pull request as ready for review 2026-10-04 13:33:48 +08:00
k88936 added 26 commits 2026-10-04 13:36:54 +08:00
- 新增 ADR「模型权限与并发上限的强制边界」:并发只在认领事务按账号过滤 running job、模型档缺省 Basic、直连同步生成暂不计入
- 技术设计新增 §11,承接并作废 §9/§10 的「只落字段、不拦截」表述
- CONTEXT 修订「模型权限」「并发上限」词条,新增「独立生成任务」
- docs/README 与 decision-log 登记本 ADR 与拷问定案
- 新增 §11.4:module-runtime 的 AgcModelAccess / 解析错误、spacetime-module 的 (owner_user_id, status) 索引与认领过滤、api-server 的档位解析与 403 错误码、后台 access 字段
- 明确默认模型语义:登记默认项优先,不在该档可用集合里时回退目录顺序首个可用项
- 记录本期复用 get_profile_recharge_center 解析档位的性能 TODO
- 新增 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:显式越权不静默回退,默认模型优先登记项、不可用时回退目录顺序首个可用项
- 覆盖缺省解析、档位过滤、越权与未知模型区分、无可用模型等纯函数测试
- 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 并补单元测试
- AdminAgcModel 新增 access 字段(缺省 basic,失败开放),GET 回读 PUT 写入
- api-server 保存时校验权限档只允许 basic/full
- 后台 AGC 模型页新增「权限档」列,可按模型选择基础/高性能模型
- fake API 与页面用例覆盖权限档选择与保存
- 新增 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 模型并回退默认项、越权错误码与状态映射
- 数据契约 external_generation_job 增补 2026-10-03 会员并发上限:新增 (owner_user_id, status) 索引、认领只算 running、过期回收豁免、缺行失败关闭到 Normal
- 设计文档 §11.4 增补落地状态与提交号,并标注账号档位轻量读与客户端 403 重选提示为后续可选项
- decision-log 追加落地实现条目:实现拆分、Basic/Full 与 Normal 的失败语义、验证结论与边界 TODO
- 新增 ExternalGenerationOwnerConcurrencyCache:单次 claim 事务内按账号只查一次 concurrent_job_limit,running 计数按账号缓存
- 认领 / lease 耗尽终结后失效该账号 running 缓存,判定结果与逐候选行现查一致
- 回收豁免与不限档不再触发 running 计数查询;补缓存失效单测
- 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 追加分桶决策、兼容与客户端口径
- module-runtime 新增 RuntimeProfileAgcModelAccessSnapshot / ProcedureResult 领域类型
- spacetime-module 新增 get_profile_agc_model_access_and_return:只读会员账期投影 + 档位目录 model_access,缺目录行失败关闭 Basic
- 重新生成绑定并补 spacetime-client get_profile_agc_model_access 与结果映射
- api-server llm/model_access.rs 改调 get_profile_agc_model_access,不再走带账期刷新的 get_profile_recharge_center
- 失败关闭语义不变:procedure 失败或档位目录缺行仍按 Basic
- 技术设计 §11.4:api-server 改调 get_profile_agc_model_access_and_return,性能说明去掉 TODO 并登记提交
- decision-log 追加「模型权限改走只读 procedure(性能收口)」条目
- 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
- 新增 packages/shared/src/llm/modelCatalog.ts,re-export Rust 生成的目录 DTO 与枚举
- 根 index.ts 增加 export type * from './llm/modelCatalog',AGC 从 @genarrative/shared 根导入
- llm_catalog.rs 删掉手写 WireModelCatalog/WireModelSummary 与 map_catalog,直接反序列化 LlmModelsResponse
- 旧服务端缺 agentMode/protocol/unavailableModels 时按 Codex + OpenAI Responses + 空桶兼容
- 新增未知 reason 收口为 unknown 且 agentMode/protocol 解析为枚举的单测
- 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 用例
- claim_external_generation_jobs_tx 的 take(limit) 只检查前 limit 行,被上限拦下/lease 终结的行仍占检查名额
- 记录触发条件(负载越高 limit 越小)与未决修法(改为成功认领才计数,仍是全局 FIFO),本次不修
- resolve_requested_for 的默认分支原只校验 enabled,补回 access.allows 校验,避免默认模型解析与档位边界不一致
- count_running_external_generation_jobs_for_owner 原先把 status 当 Rust 后置过滤,owner 的 pending 积压也会被物化
- 改为 (owner_user_id, status) 复合条件,直接走 btree 范围,语义不变
- load_owner_llm_catalog 原串行等待目录与权限档两次 SpacetimeDB 往返,热路径延迟叠加
- 改为 tokio::join! 并发发起;目录错误仍由 catalog? 返回,权限档失败关闭语义不变
- role=option 的 aria-label 原只放降级原因,覆盖了可见模型名,读屏只报「订阅计划不支持」
- 改为 `${displayName}:${reasonLabel}`,名称与原因都可被朗读
- 气泡 width:max-content + max-width:220px 配 white-space:nowrap,长文案会溢出气泡
- 改为 white-space:normal,在 220px 内换行
- 新增 MODEL_ACCESS_UNAVAILABLE(503),与 403 档位不足 / 422 模型不可用区分,不再把付费账号静默降级成 Basic
- resolve_owner_agc_model_access 返回 Result 并由 load_owner_llm_catalog 透传;缺会员行 / 缺目录行仍由 module 内失败关闭并返回成功
- 入口注释列出三类错误码语义;补错误码映射单测
- effective_profile_concurrent_job_limit 原先直接读 profile_membership.plan;该列到期后仍保留最近购买档位,过期 Max 会继续拿 128 不限并发哨兵
- 补 expires_at 有效性过滤,与 build_profile_membership_snapshot_from_row 的对外口径一致;缺会员行 / 已过期按 Normal
fix(AGC): 目录缺 unavailableModels 时不再崩溃
Project CI / Backend tests (pull_request) Failing after 1m26s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m5s
Project CI / Native shell tests (pull_request) Failing after 2m18s
Project CI / Frontend tests (pull_request) Failing after 1m7s
Project CI / Repository checks (pull_request) Failing after 1m15s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m3s
Project CI / AI game creator shell web tests (pull_request) Failing after 58s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
620f6ca938
- applyCatalog 归一化 catalog.unavailableModels,修掉按 reason 取降级文案时对 undefined 解引用(此前 26 个用例挂在这)
- 用例跟进新降级文案;disabled 用例补上下线桶,断言「该模型已下线」专用文案
Merge pull request 'Feat/pricing plan limit' (#603) from feat/pricing-plan-limit into feat/pricing-plan
Project CI / Backend tests (pull_request) Failing after 33s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m38s
Project CI / Native shell tests (pull_request) Failing after 1m34s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 3m52s
Project CI / Repository checks (pull_request) Failing after 34s
Project CI / Frontend tests (pull_request) Failing after 1m26s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m13s
0b687651a2
Reviewed-on: #603
k88936 added 19 commits 2026-10-04 22:21:30 +08:00
- packages/shared/src/contracts/generated/ 原先重复出现两次,两条中文注释描述同一理由
- 合并为单条忽略项与合并后的注释,避免后续误删其中一条造成 prettier 二次改写生成绑定
- findBy* 在超时后会直接抛错,末尾的 .not.toBeNull() 永远看不到 null
- 充值弹窗用例内的 dialog / 「可用总额」断言改为直接 await,保持意图清晰
- 原写法 plan / annual 两层三元叠加,扩展条件时容易漏分支
- 改为按 annual 分支 + 可选链与 ?? 兜底,保持 plan 为空时取 0 的语义不变
- 原年付实付文案在模板字符串里再嵌一层 savingCents 三元,属于嵌套三元
- 把年付价格行提升为 annualPriceLine 常量,JSX 只保留 annual 单层分支,文案不变
- applyWalletBalanceSnapshot 与刷新循环各自重复「resolve 视图 + 写 4 个 ready 字段」
- 抽出 readyWalletBalanceState 统一派生 totalPoints / status / error,两条路径不再有漂移风险
- 行为不变(注:本文件既有的 rejects a snapshot from another owner 用例在改动前后均失败,与本次无关)
- formatMudPointResetAt 原先每次调用都新建 Intl.DateTimeFormat,而它跑在多个渲染路径里
- locale / 时区 / 字段恒定,提升为模块级常量 MUD_POINT_RESET_FORMATTER 复用,输出不变
- public_model_catalog 里硬编码的 MODEL_UNAVAILABLE 与 AgcModelResolveError::Unavailable.code() 重复
- 改为直接取枚举 code(),与 MODEL_ACCESS_UNAVAILABLE 的既有做法一致,避免两处字符串漂移
- 有效会员的当前档位被后台临时禁用时,filter 后的 membershipPlans 查不到,状态卡退化成「会员」、按钮变「开通会员」,可在有效会员上重复开通(评审第 5 条)
- 结算弹窗升级摘要的 currentPlan 同样取自过滤列表,禁用档位会丢「原套餐标准价」;改由父组件传入未过滤目录解析出的 currentPlan,并移除弹窗对 membership 的无用依赖(评审第 11 条)
- 新增回归:active 会员 + 当前档 disabled 时仍显示「当前 Plus · 年付」、Max 提供「补差升级」,摘要保留「原套餐标准年价」
- 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 测试
- 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-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()
- 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 通过
- 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 扩展相等分支,已验证去掉 <= 时该用例失败
- 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
- 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 透传
- 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 反序列化失败
- 后台 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
- frozen_membership_cycle_matches 比较双方都按 max(1) 归一(评审第 20 条)
- 存量 cycle_index 为 0 的会员行与投影的 Some(1) 快照现在判定一致,不再永久返回「会员状态已变化,请重新下单」
- 扩展 frozen_membership_cycle_identity_rejects_advanced_cycle 覆盖迁移行;已验证去掉 max(1) 时用例失败
docs(会员计费): 同步评审第 13–20 项的契约与实现口径
Project CI / Backend tests (pull_request) Failing after 28s
Project CI / Native shell tests (pull_request) Failing after 2m6s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m17s
Project CI / Frontend tests (pull_request) Failing after 45s
Project CI / Repository checks (pull_request) Failing after 18s
Project CI / AI game creator shell web tests (pull_request) Failing after 41s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m19s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 4m55s
264f214fe5
- 后端架构文档:充点商品写入口收敛为 9 个字段,退役列保留且写死 0;首充赠送统一为 0
- 技术设计 §2.5/§5.7:补 ProfileMembershipStatusToken 与 5 个 token 枚举的 ts-rs 生成说明
- 技术设计 §11.4:AgcModelResolveError 增 NoModelForTier,模型解析错误统一走 typed AppError
- 决策记录:新增第 13–20 项收口条目,含影响范围与验证结论
k88936 added 1 commit 2026-10-05 10:51:24 +08:00
Merge remote-tracking branch 'origin/master' into feat/pricing-plan
Project CI / Backend tests (pull_request) Failing after 48s
Project CI / Native shell tests (pull_request) Failing after 3m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m21s
Project CI / Frontend tests (pull_request) Failing after 47s
Project CI / AI game creator shell web tests (pull_request) Failing after 39s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m6s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m23s
Project CI / Repository checks (pull_request) Successful in 6m31s
a48b7f8de8
# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
k88936 added 19 commits 2026-10-05 12:48:15 +08:00
- 将 membership-plans 加入 adminNavGroups 的 billing 分组,桌面端侧边栏不再漏掉「会员档位」入口
- AdminRechargeProductPage 改用严格 parsePositiveInteger 解析价格分与基础泥点
- 空串/非法输入直接提示表单错误,不再静默落库成 0 价或 0 泥点商品
- 该函数与 parsePositiveIntegerOrZero 行为一致且无任何调用点,删除死代码
- 列表与「最近保存」改用 formatMembershipPlan,未知 plan 不再渲染成空白
- ProfileRechargeProductConfigAdminResponse 已无 tier/membershipPeriodPoints 等字段,假数据同步删除,避免掩盖前后端漂移
- membershipDisplay 新增 formatMembershipStatus(normal→非会员、active→生效中,未知值原样返回)
- AdminUserDetailDialog 不再直接渲染后端 status token
- resolveMembershipPlanAction 在有效会员查不到当前档位时返回「套餐信息缺失」并禁用,避免误判为新客对所有档位开放购买
- resolveRovingIndex 的 currentIndex 允许为 -1,ArrowLeft 特判为 length-1,避免落到倒数第二项
- submittingProductId 现与会员下单共用,仅当提交的是泥点商品时才禁用整组泥点档位,避免会员提交期间跨页签耦合
- AgcModelAccess 将 Basic 放到最后一个变体并加 #[serde(other)],未知 access 不再让整份目录解析失败
- 补充未知 access 反序列化回退 Basic 的单测
- beijing_local_micros 增加 ±292_278 年范围检查,避免 days_from_civil 内部 i32 乘法溢出
- 补充 10_000_000 年等溢出用例
- AdminAgcModel.access 与 AgcModel.access 的注释不再写成「会员档位」,避免与会员 plan 档位混淆
- 充值商品页描述去掉固定比例,不再把定价规则写死在 UI
- 同步去掉公共充值弹层与测试里同口径的注释
- mudPoints 文档不再声明合计「不代表可用额度」,与钱包入口/充值弹层的「可用总额」文案对齐
- 移除 AgcModelCatalog::resolve / resolve_requested,统一走带会员档位的 resolve_requested_for
- validate 内联默认项检查,测试改用 resolve_requested_for
- LlmModelSummary/LlmUnavailableModel 反序列化缺失 protocol 时取 agent_mode 的默认协议,不再一律 openai_responses
- 补 cc / anthropic 别名 / 显式 protocol 用例;同步生成绑定注释
- LlmModelsResponse 内 agentMode / protocol 遇到未知 token 回退 Codex / 由 agentMode 推导默认协议,不再让旧客户端整份反序列化失败
- 输入校验仍走严格 parse;补未知 token 用例
- ProfileMembershipResponse.status 由裸 String 改为 ProfileMembershipStatusToken,与前端 ProfileMembershipStatus 对齐
- BFF 直接用 From<RuntimeProfileMembershipStatus> 转换,测试改用具名枚举
修复(后台兑换码): 管理拒绝补机器可读错误码走 400
Project CI / Backend tests (pull_request) Failing after 53s
Project CI / Native shell tests (pull_request) Failing after 3m51s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m25s
Project CI / Frontend tests (pull_request) Failing after 48s
Project CI / AI game creator shell web tests (pull_request) Failing after 43s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m17s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m6s
Project CI / Repository checks (pull_request) Successful in 4m37s
4f3faa04b5
- 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 与决策日志
Author
Member

集成测试:

podman + Arch Linux 容器;standalone 挂 LD_PRELOAD 时钟垫片(只改 CLOCK_REALTIME/gettimeofday/time,保留单调时钟),可把模块业务时间推进到任意日/月,ctx.timestamp 确实随之前进(本次推到 2026-12-06)。api-server 用真实时钟,会话 TTL 调长以免时钟跳跃把登录态判过期。
垫片都在容器外构建后挂载进容器。
不碰任何生产代码:充值直接调模块 procedure create_profile_recharge_order_and_return,payment_channel="mock" 在模块内立即结算;消耗/退款直接调模块钱包 procedure;登录走设备密码本地自动注册;外部依赖压到 127.0.0.1:1,容器不出网。
按你之前的要求跳过并发上限与模型权限。
结果
通过 15 / 15,exit=0: 目录种子、新用户三池、泥点充值、会员月付、扣点顺序、同天退款、跨日退款、每日重置、月付到期、年付换期、补差升级、升级拒绝、严格枚举、扣点幂等、注册奖励幂等。

集成测试: > > podman + Arch Linux 容器;standalone 挂 LD_PRELOAD 时钟垫片(只改 CLOCK_REALTIME/gettimeofday/time,保留单调时钟),可把模块业务时间推进到任意日/月,ctx.timestamp 确实随之前进(本次推到 2026-12-06)。api-server 用真实时钟,会话 TTL 调长以免时钟跳跃把登录态判过期。 > 垫片都在容器外构建后挂载进容器。 > 不碰任何生产代码:充值直接调模块 procedure create_profile_recharge_order_and_return,payment_channel="mock" 在模块内立即结算;消耗/退款直接调模块钱包 procedure;登录走设备密码本地自动注册;外部依赖压到 127.0.0.1:1,容器不出网。 > 按你之前的要求跳过并发上限与模型权限。 > 结果 > 通过 15 / 15,exit=0: 目录种子、新用户三池、泥点充值、会员月付、扣点顺序、同天退款、跨日退款、每日重置、月付到期、年付换期、补差升级、升级拒绝、严格枚举、扣点幂等、注册奖励幂等。
k88936 added 14 commits 2026-10-05 18:55:05 +08:00
- 两个 helper 原先各维护一份 reason switch,新增 reason 时容易漏改一处
- 抽成单个 REASON_COPY 表,unknown 作为未知/缺失原因的兜底,两个 helper 共用
- 表单选项与列表摘要原先各写一份档位文案,措辞还不一致
- 抽出 modelAccessLabels 单一表,表单选项由它派生,列表摘要直接查表
- membershipStatusLabels 原为 Record<string,string>,新增/改名状态时编译期无法发现漏配
- 改用 @genarrative/shared 的 ProfileMembershipStatus(normal/active),并把 AdminProfileMembershipPayload.status 的内联字面量一并替换
- 月付/年付 radiogroup 原先无 roving tabindex,方向键无法移动选项
- 复用 resolveRovingIndex 与 ref,方向键同步移动选中与焦点;锁定时不响应方向键
- 补一条周期切换键盘导航测试
- i32 年份区间内 era * 146_097 会超过 i32::MAX,debug 下 panic、release 下静默回绕
- 年、月、日中间量提升到 i64,返回类型仍是 i64;补 i32::MIN/MAX 边界回归测试
- 状态卡原先无条件显示「可同周期补差升档」,已是最高档时并无更高档位可升
- 父组件按启用目录计算 hasUpgradePath(存在 rank 更高的档位),无升级路径时只保留「周期切换需等到期后」
- 补 max 档与 plus 档的对照断言
- 会员订单提交时泥点结算 CTA 仍可点,点击后会被宿主 buyRechargeProduct 守卫静默丢弃
- 泥点结算弹层传 ctaDisabled,底部「充值」CTA 也在任意提交进行中禁用
- 保留上一轮「会员提交不波及泥点档位可选性」的既有行为,仅收口下单动作;补回归测试
- 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 与决策日志
- 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-contracts 为会员响应 DTO 的 plan / status / cycle_kind / model_access 字段加 deserialize_with,未知取值 fail-open 兜底到已知最高档,避免 AGC Tauri shell 等旧 Rust 客户端因后端新增 token 整包解析失败
- 入参 DTO 保持严格拒绝,不改枚举自身 Deserialize,避免 Unknown 变体污染入参
- 补响应兼容与入参严格测试;同步设计文档 §2.5 与决策日志
- module-runtime 变体 ProductIdUnparsable 改名为 ProductIdUnparseable,wire 串 product_id_unparsable 同步改为 product_id_unparseable(本 PR 未上线,不留兼容)
- 同步 spacetime-module 拒绝点、spacetime-client mapper 与 api-server 测试引用,并重新生成 SpacetimeDB 绑定
- 同步设计文档 §5.8 与决策日志
- 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 与决策日志
# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
修复(AGC模型): 添加协议字段并确保无可用模型时 unavailableModels 返回空数组
Project CI / Backend tests (pull_request) Failing after 1m3s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m16s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m23s
Project CI / Frontend tests (pull_request) Failing after 1m28s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m35s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m56s
Project CI / Native shell tests (pull_request) Successful in 9m33s
Project CI / Repository checks (pull_request) Successful in 7m9s
9a00700447
k88936 added 24 commits 2026-10-06 10:14:49 +08:00
- 新增 legacy_raw_catalog_payload_defaults_optional_fields:覆盖无 ok 信封、缺少 agentMode/protocol/unavailableModels 的旧裸目录响应
- 断言缺省按 Codex + OpenAI Responses + 空不可用桶兜底,锁定既有兼容行为
- userDetail.wallet 去掉已删除的 balance 字段,补齐 totalBalance/dailyFreePoints/monthlyPoints/refundDebtPoints/userId
- 与 recharge-orders 路由的 wallet helper、AdminUserDetailDialog 的读取字段保持一致,避免本地预览显示 undefined
- membership-plans 的 GET 列表与 POST 回显补上契约必填的 recommended
- 按后端 PROFILE_MEMBERSHIP_RECOMMENDED_PLAN 把 plus 标为推荐,避免本地预览与真实响应漂移
- PlatformMudPointWalletEntryProps.breakdown 收敛为 ProfileMudPointBalance | null
- ProfileMudPointBalanceView 是 ProfileMudPointBalance 的扩展,联合会被 TypeScript 折叠,属于多余类型噪音
- 同步删除不再需要的 type-only import
- packages/shared/src/index.ts 导出 resolveMembershipProductId
- useAccountWallet 改从 @genarrative/shared 公共入口引入,去掉对弹层私有文件结构的深路径依赖
- refreshPlans 复用 sortPlanEntries,首屏顺序与保存后一致
- 避免加载态与保存态列表顺序不一致,与档位列“按升级基准 rank”的说明对齐
- AdminMembershipOrderChangePayload.changeKind 改用 ProfileMembershipChangeKindToken
- 去掉手写的 'purchase' | 'upgrade' 联合,避免与后端 token 漂移
- 账期仅在 membership.status === 'active' 时展示,否则显示 -
- 避免从未订阅用户显示“月付 第 1/1 期”被误读为正在计费
- 会员结算 canSubmit 由 !submitting 改为 !submittingProductId,与充点分支的 ctaDisabled 口径一致
- 避免充点在途时仍能提交会员结算、被 buyRechargeProduct 静默丢弃后直接关闭弹层
- 补充反向用例:泥点订单提交中时会员结算按钮禁用
- loadClientLlmModels 已改为直接透传 Rust typed command 结果
- 用例改名为透传断言,不再期望渲染层补默认 agentMode(该逻辑已下沉到 Rust)
- ProfileMembershipResponse.status 注释改为“Rust 反序列化未知取值按 active 兜底,TS 侧不归一化”
- 同步重新生成 ts-rs 绑定,避免 Web 消费方误以为前端也会 fail-open
- ProfileMembershipPlanResponse.concurrentJobLimit 改为引用后端下发的 unlimitedConcurrencyThreshold
- 同步重新生成 ts-rs 绑定,避免哨兵值变更时注释误导消费方
- AdminUpsertProfileMembershipPlanRequest.concurrentJobLimit 改为引用后端下发的 unlimitedConcurrencyThreshold
- 同步重新生成 ts-rs 绑定
- shared AdminFormat 新增 formatAdminCurrencyCents(分 → ¥x.xx)并从 components 导出
- AdminUserDetailDialog / AdminMembershipPlanPage / AdminRechargeOrderPage / AdminRechargeProductPage 删除各自重复的 formatMoney/formatPrice
- 补充 formatAdminCurrencyCents 单测,避免后台各页面金额格式漂移
- 删除 denominator == 0 时的静默 0 元兜底,保留 debug_assert 并在 release 下除零 panic
- 与函数注释「零分母属程序员错误,应立刻暴露」保持一致,避免潜在少收金额
- 去掉锁定项的 tabIndex,原因改由 aria-label 与 hover 提示暴露,避免键盘停在不可选项
- 移除锁定项失效的 :focus-visible 样式与旧注释
- 更新单测:断言锁定项不可 Tab 聚焦且 aria-label 带原因
- 新增 RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted
- 报价遇到价格 / 每期泥点相等或倒挂时返回该变体,不再复用 InvalidQuoteInput
- rejection.error_code() 把目录倒挂映射为 InvalidPlanConfig,人读文案保留原因
- profile.rs 三处统一用 membership_upgrade_rejection_error,并重导出该 rejection 类型
- api-server 补 400 + invalid_plan_config 映射断言
- upsert_profile_membership_plan_record 不再信任入参 updated_at_micros
- updated_at / created_at 统一取 ctx.timestamp,客户端时钟无法伪造审计时间
- ProfileMudPointBalanceResponse.monthly_points 缺省为 0
- ProfileMembershipResponse 的 cycleIndex / cycleCount / cycleKind 缺省 1 / 1 / yearly
- ProfileMembershipPlanResponse 的 recommended / unlimitedConcurrency 缺省 false
- ProfileRechargeCenterResponse.membershipPlans 缺省空数组
- 补「旧服务端缺字段仍可整包解析」契约测试,身份与状态字段仍保持必填
- 后端架构文档新增 shared-contracts DTO 变更规则,并同步团队约定
- ensure_default_profile_membership_plan 从「空表整表播种」改为按目录逐档补齐缺失行,已存在行不覆盖后台改价
- 新增 missing_runtime_profile_membership_plan_rows 纯函数并在 module-runtime 根导出,回填逻辑可单测
- validate_runtime_profile_membership_plan_row_against_catalog 增加「existing 必须覆盖除 candidate 外全部档位」校验,缺行直接拒绝写入
- 更新 init 注释:懒补齐而非懒播种,全部档位补齐后幂等无写
- 补缺行拒绝与回填幂等两条目录单测
- confirmNativeRechargePayment 确认订单为 paid 且 kind=membership 时调用 notifyLlmConfigChanged
- 保留 applyCatalog 的 revision 门控不变,由配置变更事件重置已应用 revision 并重新读取
- 补「revision 不变但账号档位变化时,显式通知必须重新应用」的回归测试
- 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 边界)
Merge remote-tracking branch 'origin/master' into feat/pricing-plan
Project CI / AI game creator shell Rust crates (pull_request) Successful in 6m0s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m43s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m42s
Project CI / Frontend tests (pull_request) Failing after 1m16s
Project CI / Backend tests (pull_request) Successful in 8m51s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m34s
Project CI / Repository checks (pull_request) Successful in 5m34s
Project CI / Native shell tests (pull_request) Successful in 7m18s
796cf9843c
# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
k88936 added 2 commits 2026-10-06 10:32:20 +08:00
- captureWalletBalanceSnapshot 在 owner 不匹配时返回 null,applyWalletBalanceSnapshot 之前会直接解引用
- applyWalletBalanceSnapshot 入参放宽为 ProfileWalletBalanceSnapshot | null,null 直接返回 false
- 单测去掉非空断言,锁住「他人 owner 的快照捕获为 null 且必须被拒绝」
修复(画布顶栏): 明细弹层优先展示钱包读取失败文案
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m55s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m44s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m30s
Project CI / Backend tests (pull_request) Successful in 9m6s
Project CI / Frontend tests (pull_request) Successful in 3m31s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m45s
Project CI / Native shell tests (pull_request) Successful in 6m53s
Project CI / Repository checks (pull_request) Successful in 5m18s
3ca01351cb
- walletDetailsError 改为 mudPointBalanceError || rechargeError,避免充值弹层原始错误串(network down)泄漏到顶栏明细
- 单测补齐 AuthGate 挂载时的那次余额读取,并在 hover 后等待错误文案提交(弹层先渲染 loading 态)
- 两个原本失败的用例(ImageCanvasEditorView / createProfileWalletStore)恢复通过
k88936 merged commit 76166b08cf into master 2026-10-06 10:48:46 +08:00
k88936 deleted branch feat/pricing-plan 2026-10-06 10:48:46 +08:00
Sign in to join this conversation.