Feat/pricing plan limit #603
Reference in New Issue
Block a user
Delete Branch "feat/pricing-plan-limit"
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?
close #587
1. 模型权限解析走带写库刷新的重快照(performance · medium)
api-server/src/llm/model_access.rs每个/api/llm/*请求都调get_profile_recharge_center;它内部build_profile_membership_snapshot会先refresh_profile_wallet_expiring_points(每日免费点 + 会员账期刷新)再读投影,并多读钱包 / 商品 / 最新订单等与模型权限无关的字段。get_profile_agc_model_access_and_return(输入沿用RuntimeProfileMembershipGetInput,输出RuntimeProfileAgcModelAccessSnapshot { user_id, plan, model_access },仅 editor generation runtime service identity 可调)。它只读build_profile_membership_snapshot_readonly+membership_plan_row(...).model_access,缺会员行 / 已过期按Normal,缺档位目录行失败关闭到Basic,不刷新、不写库;api-server改调spacetime_client().get_profile_agc_model_access(...),失败关闭语义不变。9e397fe9c(领域类型 + procedure + 生成绑定 + client 访问器)、b0dc94b46(api-server 切换)、4a3e258f3(文档)。cargo test -p api-server llm51 passed;cargo check -p spacetime-module/-p spacetime-client/-p api-server --tests通过;npm run check:encoding通过。2. 外部生成认领逐候选行重查并发上限与在飞计数(performance · low)
c3c3fc5a2):ExternalGenerationOwnerConcurrencyCache在单次 claim 事务内按账号缓存有效 limit 与 running 计数;认领 / lease 耗尽终结后只失效该账号 running 缓存,判定结果与逐行现查逐位一致,不改变认领行为。3. 认领窗口
take(limit)+continue造成跨账号队头阻塞(bug · high,决定不修,已加代码内 TODOa1e2415fa)concurrent_job_limit按账号封顶来治(达上限只能留pending排队,拿不到更多 worker 槽)。available_at / created_at全局时间序物化后,只取前limit行检查。limit = work_slots.available_permits().max(1)(external_generation_worker.rs:137),池子 =GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY(默认 2,release 8)。所以limit ∈ [1, 8]:空载时 8,越忙越小;负载高时(7/8 worker 占用)limit = 1,正是饥饿最要命的时刻。limit = 8:需要同一饱和账号在队头占满 8 行才整轮领不到;热门账号(正因为积压才饱和)这很常见,但不是必然。limit = 1:队头任意 1 行属于饱和账号,本轮claimed就为空,其它账号全部饿死。这是真实稳态,不是理论边界。continue,却已经吃掉一个检查名额;问题不是「某账号跑太多」,而是「饱和账号的旧行把别人挡在窗口外」,且窗口越忙越小。take(limit)改为遍历全部候选,成功认领一行后才claimed += 1,if claimed.len() >= limit { break; };被跳过 / 被 lease 耗尽终结的行不消耗认领名额(终结不占 worker 槽)。仍然是 FIFO、先到先得,只是不让挡路的行占名额;配合第 2 条的按账号缓存,查询上界从 O(候选行) 降到 O(不同账号数)。available_at排序」的语义,还要处理每账号上限与不限档(128哨兵)的交互。claim_external_generation_jobs_tx的take(limit)上方留 TODO(a1e2415fa),说明触发条件与未决修法,避免遗忘。真要做时:A 改动小但会放宽单事务写集上界(需确认预算),B 属策略变更。1.
resolve_requested_for默认模型分支漏掉档位校验(maintainability · medium)module-runtime/src/agc_model_access.rs的None分支先default_model_id_for(access)拿 id,再find(|m| m.id == id && m.enabled),只校验enabled。default_model_id_for的隐式不变量;它一旦被重构或查找漂移,可能把Full模型发给Basic账号。7c57f5666):改用m.is_available_for(access)(=enabled && access.allows),边界自洽。当前行为不变(default_model_id_for本就只返回同档模型),属防御性收紧。cargo test -p module-runtime --lib agc_model_access9 passed。2. 锁定模型选项的
aria-label覆盖了模型名(bug · low)ConversationModelSelect.tsx的不可用项role="option"写aria-label={reasonLabel},覆盖了可见子文本{model.displayName}。a02a80ad1):改成`${model.displayName}:${reasonLabel}`,名称与原因都能朗读。tsc --noEmit通过。3. 读链路失败被静默降级为
Basic(bug · medium)api-server/src/llm/model_access.rs对get_profile_agc_model_access的任何Err都warn!后返回Basic。ok: true+Basic,所以Err只剩传输 / 超时 / 鉴权失败;把它降级成Basic会让付费Full账号收到 403plan_required、列表里丢掉Full模型,且只有一条 warn、没有对外降级信号。7b014bdc0):新增独立错误码MODEL_ACCESS_UNAVAILABLE(503),与 403MODEL_NOT_AVAILABLE_FOR_PLAN、422/503MODEL_UNAVAILABLE区分;resolve_owner_agc_model_access返回Result并由load_owner_llm_catalog透传,不再静默降级;入口注释逐条列出三类错误码的语义与状态码;补错误码映射单测。cargo test -p api-server llm52 passed。4. 目录与权限档两次串行 SpacetimeDB 往返(performance · medium)
load_owner_llm_catalog先 await 目录 procedure,再 await 权限 procedure,两次独立往返串行。576d2a91a):tokio::join!并发发起两者;catalog?仍返回目录错误。cargo test -p api-server llm52 passed。5.
revision: u64导出成 TSnumber的精度(maintainability · low)shared-contracts/src/llm_catalog.rs的revision: u64带ts(type = "number"),前端消费number。revision是目录乐观锁自增计数器(测试里是 4),不是时间戳,2^53 不可达;decision-log 明确选了number(#[ts(as = "f64")])以免前端 BigInt。6. 过期会员仍保留高并发上限(bug · high)
plan自动改成Normal?」:设计上profile_membership.plan故意保留「最近一次购买的档位」——refresh_profile_membership_cycle到期只把status置Normal、清cycle_remaining_points,plan不动,供展示「已失效的套餐」;对外「当前档位」由build_profile_membership_snapshot_from_row读取时按expires_at派生(过期 →Normal,代码注释也这么写)。effective_profile_concurrent_job_limit直接读了这个原始列,绕过派生,于是过期Max继续拿128(不限并发)哨兵,与快照口径不一致。0d71c1dd0):读取时加active_membership_row_at(row, ctx.timestamp)过滤(等价于快照的expires_at > now),过期回落Normal。plan写成Normal会丢掉「曾购买档位」信息,且存量已过期行要回填;读取侧派生是现有设计,改一处即可。若你要「列即当前档位」,那是另一种数据模型,需要迁移 + 回填。cargo test -p spacetime-module265 passed / 1 ignored。7. 跨账号队头阻塞(bug · medium)
claim_external_generation_jobs_tx仍candidates.into_iter().take(limit),被上限拦下 / lease 耗尽终结的行只continue,却占掉一个检查名额。limit= worker 空闲槽(默认 2 / release 8,负载高时降到 1)。claimed可能为空。ad7de7304)。claimed.len() >= limit才 break,被跳过 / 终结的行不占名额;仍是全局 FIFO。改前确认单事务写集上界。8.
running计数把 status 当 Rust 后置过滤(performance · medium).filter(&owner_user_id.to_string())再.filter(|row| row.status == RUNNING),owner 的pending积压也被物化(每次缓存 miss)。ad59f420a):.filter((owner_user_id, EXTERNAL_GENERATION_STATUS_RUNNING)),status 推进(owner_user_id, status)btree 范围,语义不变。cargo test -p spacetime-module265 passed。9.
input.user_id.clone()可以改成 move(maintainability · low)—— 评审不成立get_profile_agc_model_access_and_return里build_runtime_profile_membership_get_input(input.user_id.clone())。ctx.try_with_tx签名是body: impl Fn(&TxContext),闭包借用捕获input,move 出字段报 E0507(实测编译失败)。保持.clone(),取消该建议。10. 锁定模型原因提示长文案会溢出气泡(style · low)
.conversation-model-menu-option-locked[data-tooltip]::after用width: max-content; max-width: 220px; white-space: nowrap,长文案不换行、溢出 220px 气泡。ec73b9cae):white-space: normal。tsc --noEmit通过。11. 额外发现:前端模型选择测试套原本全红
ConversationModelSelect.tsx的applyCatalog直接解引用catalog.unavailableModels(setUnavailableModels与按 reason 取文案两处);目录响应缺该字段(旧缓存 / 测试 mock)时 state 变undefined,.find/.map抛 TypeError。conversationModelSelect.test.tsx26 failed / 3 passed,且用例还在断言旧降级文案所选模型已停用,已切换为默认模型。4c7ef1673):applyCatalog归一化catalog.unavailableModels ?? [](与initialCatalog的既有守卫一致,契约注释也写明「旧服务端缺字段时为空」);用例跟进新文案,disabled用例补上下线桶并断言「该模型已下线,已切换为默认模型」。modelAvailabilityCopy.test.ts2 passed;chatDialogFrameLayout.test.ts+ 组件自带用例 14 passed;apptsc --noEmit通过。- modules/game_distribution.rs:新增 POST /api/game-distribution/games/{game_id}/plays,公开可带 bearer,非公开 404、限流 429、成功 200 {recorded} - 身份组成:登录用 userId、匿名用 clientId、都缺失回退 IP+UA;无效 bearer 按匿名处理不影响计数 - 请求体读取原始 Bytes,空体或畸形体不阻断计数- 玩法链路:路由表新增 POST /games/{gameId}/plays,并新增「游玩计数(已实现)」小节说明触发、落点、缓冲延迟、去重限流与写入语义 - 后端架构:game_distribution_game 节补 play_count 的批量 procedure 写入路径与参数 - decision-log:记录内存去重缓冲 + 批量 procedure 落库的决策、失败语义与影响范围- gameDistributionClient.ts:recordGamePlay 标注 Promise<{ recorded?: boolean }>,空 id 分支也返回同形状对象,消除 Promise<void> 与对象 Promise 的联合类型/pay/<checkoutToken>的前缀路由(Nginx 三模板 + Pingora + 门禁) ad0430fcd2gamePlayClientId.tsto module and test exclusions- role=option 的 aria-label 原只放降级原因,覆盖了可见模型名,读屏只报「订阅计划不支持」 - 改为 `${displayName}:${reasonLabel}`,名称与原因都可被朗读a1e2415faeto620f6ca938