Feat/陶泥儿会员订阅方案 #588
Reference in New Issue
Block a user
Delete Branch "feat/pricing-plan"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
首充优惠删除了
每日免费泥点暂时没删, 改为前端得知每日免费泥点为0的时候隐藏文案
之前的会员删除了, 没有迁移
close #586
- 新增 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>1.
civil_calendar.rs:57weekday_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-1265RuntimeProfileMembershipPlan文档与实现不符(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 tokeninvalid_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-155saturating_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:176runtime_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-27beijing_local_micros文档返回说明错误(documentation · low)现状实现:日期非法时
return 0(Unix epoch)。问题:文档写「本地日期不存在则返回原值」,与实现相反,会误导调用方以为拿到原值且无错误信号。审查意见属实。
建议修复:改文档说明返回
0,或改签名为Option<i64>/Result。选改文档(非破坏性)。提交:
5ab6f161d 文档(计费): 修正北京本地时间换算的返回值说明9.
civil_calendar.rs:39civil_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:24time_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-37cycle_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.ts14/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_snapshotfacade、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-clientfacade、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 /这种悬空斜杠。cycleKindAfter ?? cycleKindBefore,仅在有值时才追加/ ${formatMembershipCycleKind(cycleKind)}。2. 泥点商品列表为空时表单残留旧值(bug · low,
AdminRechargeProductPage.tsx:75-79)refreshProducts只在sortedEntries[0]存在时fillForm;过滤后为空就保持上一次表单值(或默认值)。DEFAULT_RECHARGE_PRODUCT_FORM,列表为空时调用resetForm()回到新建默认值。3. 同名整数解析器语义分裂(maintainability · low,
AdminProfileWalletConfigPage.tsx:209-215与AdminRechargeProductPage.tsx)parseNonNegativeInteger:账号配置页返回number | null(非法即拒绝),充值商品页返回number(负数/非法压成 0);会员档位页还有第三份parsePositiveInteger。parseInt会把'12abc'截断成12。pageUtils.ts提供严格版parsePositiveInteger/parseNonNegativeInteger(Number+isSafeInteger,返回number | null)与落库宽松版...OrZero,三个页面删除本地副本,统一走共享实现。4. 假后台保存会员档位后回显错档(maintainability · low,
scripts/admin-web-fake-api.mjs:768-769)plans[1](starter)。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,旧 tokenmonth/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。!membership冗余条件可顺手清掉,但无行为影响,未改代码。7. 升级报价未按周期匹配(bug · medium,
PlatformProfileRechargeModal/index.tsx:930-933)quoteForTarget只比对membershipQuote.targetPlan === target.plan。membershipQuote.cycleKind === target.cycleKind,周期不一致按“无报价”处理。8. 报价错误态嵌套三元(style · low,
PlatformProfileRechargeModal/index.tsx:1011-1012)errorState内联error ? ... : isLoading ? ... : null的嵌套三元,违反项目风格约束。resolveMembershipQuoteErrorState({ isUpgrade, error, isLoading, onRetry }),用早返回表达三种状态。9. 方向键/Home/End 切换后焦点不跟随(maintainability · low,
PlatformProfileRechargeModal/index.tsx:484-487)onChange(nextTab),没有把焦点移到新选中的 tab;档位网格同样问题。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 档”。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 交给同一条下单链路”不符。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)所以没暴露。CycleKindChangeUnsupported测试。13. 档位校验未覆盖展示型权益(maintainability · low,
membership/catalog.rs:162-167)validate_runtime_profile_membership_plan_row_against_catalog只校验 rank 唯一与月价/年价/泥点单调。model_access与concurrent_job_limit不在校验内,后台可存出高档位但权益更差的倒挂目录;代码又确实把它们当展示字段。14.
membership-plansTab 未登记导致非 owner 恒 403(bug · high,server-rs/crates/api-server/src/admin.rs:2241)membership-plans,但shared-contracts/src/admin.rs的ADMIN_TAB_PERMISSIONS没有它。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”)仍在。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)— 经复核为非问题admin_upsert_profile_membership_plan接收带updated_at_micros的输入并原样落库。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枚举已存在。build_runtime_profile_membership_upgrade_quote_record用as_str()转回对外 token(HTTP 契约不变);spacetime-clientmapper 改用 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。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失败。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(甚至已过期)仍拿到成功报价,报价结果内部自相矛盾;而该函数同时服务报价与下单路径。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。advance_beijing_months_clamped会把0当作锚点继续算。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只匹配会员升级被拒绝:、会员档位前缀和一组精确文案。充值商品类型无效:会员商品已迁移到会员档位目录(后台仍 POSTkind: "membership")与会员商品 product_id 无法解析: …(存量待支付会员订单)这类可读业务拒绝落到 502 BAD_GATEWAY。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 时调用,读取路径(充值中心、下单快照、升级报价)不再懒播种。profile_membership_plan为空,充值中心无会员档位,下单/报价返回会员目录缺少档位,直到管理员手动 upsert 一次才补齐。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分支没有。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上,造成多发泥点。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 —— 嵌套三元
disabled ? actionLabel : submitting ? '处理中' : '选择并确认'。987e82605)。在 return 前用 if/else 计算actionButtonLabel。9. packages/shared/src/components/PlatformProfileRechargeModal/index.tsx:519-527 —— 方向键索引逻辑重复 + 嵌套三元
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 语义,改为普通文本容器,样式不变。脚本
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。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 期。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 = 0resolve_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。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。"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,等于丢失档位 / 权益 / 升级资格。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。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 项再生)
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-resetledger。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注释也写「不写库」。先回答你的疑问:
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 时完成。为什么要有懒播种:
initreducer 只在数据库创建时跑一次,后续增量发布(给已存在的库加表 / 加字段)不会重新触发 init。所以「库里已有profile_membership_plan表但当时 init 没种过」这种存量库,如果只靠 init,目录会一直为空;懒播种是给这类库兜底。代价就是「只读」procedure 首次调用会写库,和注释矛盾。所以这条不是「改个注释」就能了事:
ensure_default_profile_membership_plan的懒播种注释和只读入口注释分别描述了两种设计意图,代码同时满足不了。需要你先决定播种策略,三条路选一: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失败。先回答你的疑问:
旧迁移包是什么?export_database_migration_to_fileprocedure 把整库数据导出的一个 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里补这些列的缺省回填。profile_recharge_order和profile_membership补or_insert(Null / 0 / 默认枚举),且profile_membership.plan的回填值要与第 5 项的回填结论一致。附注:
Month/Season/Year映射、第 20 项建议与懒播种注释冲突)。tscguardrails、组件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。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。git log dfa92607b..HEAD -- server-rs/crates/spacetime-module/src/migration.rs为空,本分支提交没有碰migration.rs。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/ProfileMembershipModelAccessunion。会触发 422 的来源:绕过前端、直接向该接口发 JSON 的调用方——例如手写 curl / Postman / 脚本、联调时的旧客户端、或将来某个没复用 union 的新调用方,把
plan写成"proo"/"Plus"/"full_access"之类。行为对比:改之前
plan是String,handler 里parse_runtime_profile_membership_plan解析失败返回 400;改之后 serde 在反序列化阶段拒绝,AxumJson提取器返回 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",清空后原生校验也不拦截。parsePositiveInteger,解析为null时先提示表单错误并return(在confirmWrite之前)。5a776f61c。3.
apps/admin-web/src/pages/AdminRechargeProductPage.tsx:32-36—— 默认 SKU 与定价比例硬编码points_60/ 600 分 / 60 泥点)与「¥1 = 10 泥点」同时写在顶部常量、页面 description 和注释里。985b8718b。4.
apps/admin-web/src/pages/pageUtils.ts:66-68—— 无调用方的重复 helperparsePositiveIntegerOrZero与parseNonNegativeIntegerOrZero对0/非法输入行为一致。parseNonNegativeIntegerOrZero全仓无调用点,属死代码且与另一个语义重复;严格版parseNonNegativeInteger仍在AdminProfileWalletConfigPage使用。parseNonNegativeIntegerOrZero。6e419d0d8。5.
apps/admin-web/src/pages/AdminMembershipPlanPage.tsx:397—— 未知档位 code 渲染为空白membershipPlanLabels[plan]。undefined,React 渲染成空白。formatMembershipPlan(未知值回退原 code);表单选项仍复用membershipPlanLabels,保留导入。0a6c70a64。6.
scripts/admin-web-fake-api.mjs:809-810—— 假数据残留已退役字段tier/membershipPeriodPoints/membershipPeriodDays/membershipQueueLimit/membershipDiscountBps。ProfileRechargeProductConfigAdminResponse与adminApiTypes.ts移除,假数据会掩盖前后端漂移。bonusPoints/durationDays仍是契约内退役列,保留为 0)。589a2623a。7.
apps/admin-web/src/components/AdminUserDetailDialog.tsx:544—— 会员状态直接渲染后端 tokenplan/cycleKind已本地化,但status仍是normal/active原样渲染。membershipDisplay新增formatMembershipStatus(active→生效中、normal→非会员,未知原样返回),此处复用。ff2bd5b57。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 不变就不更新界面」当作既定行为断言。评审建议的「每次成功都同步桶」会与该用例冲突。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 变 502map_runtime_profile_client_error只认ProcedureRejected/Runtime,其余SpacetimeClientError::Procedure(String)一律 502。bd06e4269删除中文文案匹配后,admin_disable_profile_redeem_code这类仍只带裸error_message的后台兑换码结果(如兑换码不存在)从原来的 400 domain 拒绝变成 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,低于阈值。10_000_000级用例。8bc48e9e6。14.
server-rs/crates/shared-contracts/src/runtime.rs:437-438—— 客户契约status仍是StringProfileMembershipResponse.status是String,而plan/cycle_kind已收紧为 token enum;ProfileMembershipStatusToken已存在且 admin payload 在用,module-runtime 也已有From<RuntimeProfileMembershipStatus>。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-runtimeAgcModel.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)}禁用全部泥点档位。membership-<plan>-<cycle>)也会禁用泥点档位,而泥点「充值」CTA 只看selectedPointProduct仍可点,出现跨页签耦合。d3f02d6bc。18.
server-rs/crates/module-runtime/src/agc_model_access.rs:171-175—— 遗留无档位 resolver 与新 resolver 并存AgcModelCatalog::resolve/resolve_requested(只看enabled+id)仍公开,生产调用方已迁到*_for(access),但旧方法仍可被调用。AgcModelCatalog::resolve/resolve_requested;validate内联「默认项存在且启用」检查;相关单测改用resolve_requested_for。7579213be。19.
server-rs/crates/shared-contracts/src/llm_catalog.rs:147-149——protocol默认值不感知agent_modeLlmModelSummary/LlmUnavailableModel.protocol用#[serde(default)],缺省落到AgcModelProtocol::default()=openai_responses,与agent_mode无关;而Cc的默认协议实际是anthropic。agentMode: "cc"且缺protocol会反序列化成不合法组合Cc + OpenAiResponses,与AgcAgentMode::default_protocol()矛盾。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 客户端整体反序列化。agentMode/protocol回退到Codex/agent_mode的默认协议,旧客户端不再整份失败;enum 本身不新增变体,写入/输入校验仍走严格parse,因此不影响请求侧门禁。4bd080eff。月价 / 年价输入框改为 min={isPurchasable ? 1 : 0},normal 档(0 价)不再被浏览器原生校验拦住 下拉选到目录中不存在的档位时清空表单并置空 rankLabel,避免提交上一档的陈旧值 Co-authored-by: Junie <junie@jetbrains.com>- role=option 的 aria-label 原只放降级原因,覆盖了可见模型名,读屏只报「订阅计划不支持」 - 改为 `${displayName}:${reasonLabel}`,名称与原因都可被朗读集成测试:
unavailableModels返回空数组