Skip to content

LLM 调用统一到服务端 + 成本可归因 + 反滥用配额 —— 实施 plan

物理位置:~/reading-browser/docs/plans/llm-server-unification-plan.md 来源:docs/plans/backlog.md §「🟡 LLM 调用统一到服务端 + 成本可归因 + 配额」(2026-08-02 讨论记录) 创建:2026-08-02 · 状态:全部完成 ✅(WP0-WP5 含 WP4d,+ lookup-or-fetch-word 加闸两步,2026-08-03 收口)

最后一项(已完成)lookup-or-fetch-word 加闸 —— 三个裸 function 中唯一还活着的 (google-books-proxy / gutenberg-proxy 已确认死代码并删除,线上 14→12)。 阻塞已解除(2026-08-02):RVH 会话回填 docs/cross-end/18-rvh-edge-jwt-handoff.md §六, 结论 = supabase Dart SDK 的 functions.invoke 自动携带用户 access_tokenAuthHttpClient 每次请求现取 accessToken ?? anon_key),RVH 无需改代码; 且 RVH 有硬 auth gate,未登录不可达任何功能页 → anon 分支在 RVH 不可达。 加闸判据须为「无 sub 才拒」(RVH 送标准 role=authenticated token,无自定义 claim)。

加闸拆两步(2026-08-03)——因为上述结论是客户端 wire 探针得出的, RVH 自己写明「服务端观测到的 userId:未观测」。直接上 401 的风险 = RVH sync 期 补词静默挂掉。

  • 第一步 ✅ 已部署并实测(2026-08-03):anon key 打 lookup-or-fetch-word200(确认不拒绝),user_quota_usage 出现 meter=dict_lookups 哨兵桶行、 units=2 与发出的 2 次成功调用精确吻合(另有 1 次 HTTP 000 未达服务端,正确未计数)。 同批重部了其余 9 个 _shared/quota.ts 消费方,anon+合法 body 冒烟全数 401 = 拒匿名闸无回归。 代码见 1748be6:解身份 + bumpQuota('dict_lookups')
    • [jwt-probe] 日志(带 x-client-info 以区分 RB/RVH),不拒绝任何请求。 零风险,且顺带补上这个 function 一直缺的账本(它此前完全在账本外)。 新 meter 不需要 DDL(meter 是无 CHECK 的 text);dict_lookups_limit故意先不建——checkQuota 对缺列 fail-open 放行,正是第一步要的。
  • 第二步 ✅ 已部署(2026-08-03)。放行条件已由服务端实测满足: 2026-08-03T01:51:41.949Z [jwt-probe] userId=<test-user-id-A> client=supabase-flutter/2.12.0 —— RVH 用 Dart SDK 调本 function 且携带用户 JWT,客户端 wire 探针的结论至此有服务端背书。 (附带发现:RVH 在缓冲池已有该词时仍然调了 edge function,说明其 getVocabulariesBatch 不以缓冲池命中为前提;原计划"删缓冲池行来逼出调用"的步骤因此没必要。) 已加 unauthenticatedResponse() + checkQuota + 执行 dict_lookups_limit DDL(默认 30000)。 实测:anon+合法 body → 401 authentication_required;真实用户 token → 200 + 完整释义; 畸形 body 仍 400(校验在闸前);401 不计配额(闸在 bumpQuota 之前)。

上游硬化(2026-08-03,加闸之后的独立一轮):用户实测「第一次双击没释义、第二次才有」 引出的。根因不是 RB —— 是 lookup-or-fetch-word404(词典里真没有)5xx/网络(这次没查成) 合并成同一个 200+null,于是上游一抖就谎报「无释义」, 用户以为这词没释义便不再试。

修法分两段。第一段(治标):5xx 重试 + 响应头 X-Dict-Upstream: unavailable 标记, 客户端据此显示「查询失败·重试」;标记走 header 不走 body 是必须的 —— RB/RVH 的行模型字段全可选,返回 {"error":…} 会被 serde 反序列化成一行全 None 的 垃圾数据插库。

第二段(治本,用户提议后实测确认):主源从 api.dictionaryapi.dev 换成 Wiktionary(Wikimedia)。四条依据:① 同源 —— 前者本就是 Wiktionary 的二手包装, 实测 susurrus 两边音标逐字符相同,换源不掉覆盖率;② 可用性差一个数量级 —— Wiktionary 连打 10 次全 200,dictionaryapi.dev 同期同一个词三次给出三种结果; ③ 404 语义干净 —— 后者对假词也返 502,正是本 bug 的根源,换源等于从根上消除; ④ 音频无所谓 —— pronunciation_url 在 RB 全仓只有写入、无任何读取路径(发音走 Azure)。

IPA 不在 Wiktionary 的 REST 端点上,改走 Action API 取 wikitext 正则抽取 (只在 ==English== 段内找 —— 同页常含拉丁语等其它语言,抓错语言的音标错得很隐蔽); 两个端点并行发,延迟取 max。实测 9/9 干净命中。 代价 = 新回填词没有结构化同义/反义词(Wiktionary REST 不分字段),对长尾生僻词 「释义 > 音标 > 同反义」的优先级下划算。Free Dictionary 降为备源, 观察后大概率退役(度量口 = 日志里 [via=fallback-rescued] 占比,见 backlog)。

加闸位置的既有事实(2026-08-03 冒烟时厘清):9 个带闸 function 的身份闸都在 body 解析/校验之后(如 breakdown-sentence:94 校验 → :101 闸)。匿名请求拿畸形 body 会在校验处 200 短路、走不到闸 —— 这不是漏洞(只换来一次 JSON 解析,无 LLM 无 DB), 但本文档此前"入口加闸"的措辞应读作「业务逻辑之前」而非「handler 第一行」。 冒烟测试务必送合法 body,否则会把 200 误判成闸失效。

WP4d as-built(2026-08-02):原定「等一个版本周期」的硬序在执行前被证伪并提前执行—— 旧路径运行时已不可达(translateParagraphs 零调用方 + 4 个设置字段只写不读), 留着不构成回退路径;而设置面板仍在展示静默失效的 API key 输入框。详见 §WP4d。

WP3 阶段 2 as-built(a944797 + 8156910:9 个带闸 function 入口 if (!userId) return unauthenticatedResponse()(401,置于配额闸之前); checkQuota 改身份缺失 fail-closed(与基础设施错误的 fail-open 相对)。 客户端 ensure_fresh_access_token重试一次 + 6 个传播型调用点改 require_user_token(比原 unwrap_or_else 更短,错误消息从「Remote rejected (401)」 变「请重新登录」)。实测:anon key 打带闸 function 全返 401, 未带闸的 lookup-or-fetch-word 仍 200(RVH 查词链路不受影响)。

⚠️ 阶段 2 是抬高门槛不是堵死:Supabase 允许匿名注册,批量注册仍能拿新配额。 真正收口要配合邮箱确认 + 注册频率限制(另一件事)。价值在于把「零成本滥用」 变成「有成本且可追溯的滥用」。

一处过度设计已回退(8156910:曾给 3 个 graceful 命令加早退分支以省掉 注定失败的往返 —— 但它们本就有 if !resp.status().is_success() { log::warn + 返回空 }, 401 零代码即正确降级。为「token 临近过期 网络连续失败两次」才触发的场景 加 21 行客户端分支,不划算。净减 28 行。

code-review(6366f5d..d6af447:P0 = 0;P1 = 1(TTS 账本给被拦请求记假成本, d6af447 已修,赶在线上数据变脏之前);P2 = 3(匿名闸单位错配 / 小时闸计入被拦尝试 / page_annotations.provider 恒 null),前两个已随 702cd78 修掉。 WP5 另修 8 处文档漂移 —— product.md 曾拿 ✅ 声称已删的「双语模式」仍存在。

阈值调优不需要改代码quota_config 表里一条 SQL UPDATE 即可(60s 缓存后生效)。 当前刻意宽松,建议账本积累几周后按真实分布收紧。

已落地e41b86e(WP0 首批 + DDL)· b2adf9c(WP1+WP2,8 个 function 已 deploy)· 672cc6f(edge-deploy skill 修正)· 92ed591WP0 补丁:补齐漏掉的 4 个文件 + WP4e)。

WP0 曾只做了一半ensure_fresh_access_token 只接到 supabase.rs,而 analysis.rs / report.rs / tts.rs / pronunciation.rs 也在调 edge(共 9 个调用点)。成因:设计复查纠正了 「supabase.rs 内部是 4 edge + 1 PostgREST」,却没质疑「只有 supabase.rs 调 edge」这个前提 ——复查了数字,没复查范围。已于 92ed591 补齐,9 点全走用户 JWT (唯一例外 supabase.rs:239 PostgREST 缓冲池,F1 明确不动)。

端到端验证通过(真实登录用户)tts_call_log.user_id = 真实 uuid;char_count 5/7 → cost 0.000080/0.000112 与 字符×$16/1M 精确吻合;user_quota_usage 用户行 tts_chars=12=5+7。 匿名哨兵桶停在冒烟值不再增长 = 归因确实生效。

本机已装 Deno 2.9.4,本 plan 触达的 9 个 edge function 现已全部 deno check 通过 (全仓其余 function 仍有既有 never-泛型噪声)。

WP4a-c 已落地4133e15,净删 587 行):新建 translate-batch edge function、 两个调用点改走 edge、全页双语整块退役。实测 3 段输入 → 3 段译文顺序对齐、 translate_paragraphs units=3 与送去段数精确一致。 WP4d 未做(plan R2 硬序):translate_paragraphs / DeepL / call_chat_api / 设置项全部保留作一个版本周期的回退路径。

⚠️ 验证方法勘误:早期几次报告里写的 tsc --noEmit ✅空跑——根 tsconfig.json 是 solution-style(files: [] + references),不带 -b 什么都不检查。 真正的类型检查一直由 pnpm build(= build:cs && tsc -b && vite build)承担, 所以结论本身没错,但那条单独列出的 tsc --noEmit 是 no-op、不构成额外门禁。 单独跑请用 pnpm exec tsc -p tsconfig.app.json --noEmit

设计复查(2026-08-02,动工前):查出 6 处问题,5 处已修进本文,1 处待用户决策。 ① WP0 调用点清单错误——supabase.rs4 edge + 1 PostgREST,后者(缓冲池,RLS 无仓内定义)不动; ② WP0 缺 token 过期防回归——anon_key 永不过期而 access_token 会,直接换会种下「查词偶发 401」新 bug; ③ user_quota_usage.user_id NOT NULL 与「匿名共享桶」矛盾 → nil UUID 哨兵; ④ WP4b 交互形式 → 已定方案 B(2026-08-02):只删全页双语,救援交给已有的选区翻译,不新增 hover 图标; ⑤ 多数 WP 缺验收标准 → 已补;⑥ WP4e 排位 → 置于 WP1 之后。


一、Context —— 这个任务解决什么、交付什么

三个彼此独立、但共用同一套前置的问题

问题 1:还有一个功能要用户自己填 API key(体验不一致)

客户端还持 key 的只剩「翻译」一个——translate_api_keyTranslationSettings.tsx 密码框)→ translate.rs::translate_paragraphs 直连 DeepSeek / DeepL / OpenAI / custom。其余 9 个 LLM 功能

  • TTS 已全部服务端持 key,走 _shared/llm.ts

现状本身就不自洽:点「拆解」不用填 key,点「翻译」要。而且绝大多数用户根本没填 key → 翻译对他们是个死功能bilingual.js:132 无 key 直接 disableBilingualMode())。

问题 2:服务端花的钱不可归因(今天就存在的敞口,不是将来的)

  • supabase.rs 所有 edge 调用拿 anon_key 当 Bearer(107/238/356/583/737 行),不带用户 JWT
  • llm_call_logobservability-tables.sql:16user_id
  • TTS 完全在账本外tts-synthesize 不写任何日志;issue-speech-token 把真的 Azure token (TOKEN_VALIDITY_SECS = 600)发给客户端直连,结构上不可计量
  • _shared/governance.ts 的月度预算闸只挂推荐链路,用户链路 6 个 function 只记账不设闸

→ 出现异常花费时,你只知道"这个月贵了",不知道是谁、是 bug 还是滥用。而 anon key 是公开的 (by design),任何人可以直接打 edge function 烧真钱。

问题 3:全页双语与产品定位冲突(产品问题,本轮讨论新增)

bilingual.js 的全页双语一旦开启:眼睛走中文 → 没人双击查词 → CEFR 高亮变装饰 → 语境不进 cloze 池 → SRS 无新卡。产品在那一刻退化成"带译文的浏览器",词库 / 消歧 / 语境池的全部工作 不产生价值。且全页翻译是彻底的红海商品(沉浸式翻译 / DeepL / Edge 内置),把产品放进错误的竞品集。

交付什么

WP0  edge 读用户 JWT sub(有=用户,无=匿名)+ token 续命防回归   成本可归因的硬前置
WP1  账本补全:llm_call_log +user_id / tts-synthesize 记账
     / issue-speech-token 记发放
WP4e 删「系统语音」选项 + 默认切 Azure                        独立·便宜·修今天就在犯的病
WP2  user_quota_usage 计数器表(RLS 只读自己)+ bump_quota RPC
WP3  反滥用配额闸(单档,阈值设在典型用量 5–10x)
     + issue-speech-token 领取频次限制         阶段 2 前须 RVH 已迁(新会话)
WP4  翻译收口:全页双语退役 / 调用点走 edge / 砍 DeepL
     / 删 translate.rs LLM 路径 / 删设置项 / 不做 BYOK
WP5  收尾(文档 / DDL 归档 / 部署 / CHANGELOG)
⑤    定价 —— **移出本 plan**,只留接入点(见 §九)

WP4e 排位说明:它与 WP0-WP4 零耦合,本可随时单做;排在 WP1 之后是因为它便宜且 修的是今天就在影响所有用户的默认值问题(默认 web-speech,见 D4b),早做早收益。

价值

  1. 消除"必须填 key"的摩擦 → 翻译从死功能变成所有用户可用的功能
  2. 花的每一分钱能定位到人 → 异常花费可诊断、可封堵,而不是只能看总额干瞪眼
  3. 段落点按同时是产品修复 → CEFR 高亮 / 双击查词 / cloze 语境池在阅读全程保持在线, 直接服务「帮助用户积累词汇」这个价值中心
  4. 设计对商业模式中立 → 在「收费」和「不收费」两种前提下是同一个形状,将来往哪走都不返工

二、现状核对(2026-08-02 实测)

LLM / TTS 功能清单与持 key 位置

功能落点key 在哪记账
breakdown-sentence / resolve-context / disambiguate-sense / generate-example / lookup-or-fetch-word / judge-phrases-batch / reading-reportedgeFunction Secrets✅ llm_call_log
discover-sites / analyze-articles(推荐链路)edgeFunction Secrets✅ + governance 预算闸
翻译(3 个调用点,见下)Rust 本地settings 表(用户填)❌ 无
tts-synthesizeedge 代理Function Secrets无记账
issue-speech-tokenedge 发 tokenFunction Secrets结构上不可计量

翻译不是一个功能,是三个调用点

bilingual.js:140          全页双语   ← 唯一的异类(本 plan 改形状)
toolbar.js:174            选区翻译   ← 与其余 9 个同类
SentenceWorkbench.tsx:201 句子工作台 ← 与其余 9 个同类

三处各自读 5 个 settings key(translate_provider / translate_api_key / translate_target_lang / translate_custom_endpoint / translate_custom_model)。

全页双语的真实行为(修正 backlog 里的描述)

不是「一次开双语 = 整篇文章进 LLM」。bilingual.js:65isInViewport(el, 500)视口懒加载 + 300ms debounce 滚动增量(onBilingualScroll),单请求天然有界(视口内 5–15 段), 无界的是请求次数MAX_PARA_LEN = 800 会把长段按句拆分。译文缓存 state.cache.translationCache 只是内存态,换页即失、跨用户零复用。

TTS 的真实用量(修正早期估算)

产品里没有整篇朗读功能tts_synthesize 硬上限 MAX_TEXT_LENGTH = 500, 所有调用点都是词 / 句 / 选区级:WordAudioControls.tsx:27 / SentenceWorkbench.tsx:574 / toolbar.js:103 / PronunciationPanel.tsx:100 / InlinePractice.tsx:46

→ TTS 单位成本虽是 LLM 的 ~40 倍($16/1M 字符 vs ~$0.4/1M 字符等效),但用量小, 重度用户 ≈ $0.4/月,与 LLM 的 $0.3/月同量级,不是压倒性的

已经存在、可直接复用的基建

  • src/lib/tts.ts + content-script/features/tts.js 已是 azure / web-speech 双路由, 且 azure 失败自动 fallback 到 web-speech —— 任何降级路径不需要新建
  • edge 平台层 verify_jwt 默认开启cron-setup.sql:14 注释),且 anon key 本身就是合法 JWT (role=anonsub)→ ① 是加法不是替换
  • _shared/llm.ts::callLLM 已统一记 token / cost / latency / status
  • recommend_config 表 = 「阈值进表、key 留 Secrets」的现成模式

三、设计决策与否决记录

本节记录 2026-08-02 讨论中被否掉的方案和理由。不写下来,将来会有人重新提。

D1. key 留 Supabase Function Secrets,搬进表给 admin 界面编辑

现状 Deno.env.get('LLM_API_KEY') 在进程内存、不落库、不过网。搬进表 = 一次权限失误 / XSS 就能读出 key,且每次调用多一次 DB 读。admin 管预算参数和配额阈值,不管 key 本身recommend_config 先例)。

D2. 不做 BYOK(用户自填 key 逃生舱)

曾考虑保留 translate.rs 作为「填了 key 走本地直连」的高级选项。否决理由:

  • 受众极小:自填 key 偏技术,多数用户不知道 key 怎么申请
  • 隐私论据不成立:翻译走 BYOK 不能让「内容不过服务器」成立——另外 9 个功能 + sync 照样过。 局部逃生舱解决不了全局问题
  • 维护成本不划算:保留 = translate.rs + 3 处 × 5 次 get_setting + 设置 UI + 两条路径都要测
  • 死代码风险:参照 phrase_interaction_log(v20 建、v25 DROP,"纯只写从无 SELECT = 死表")的教训

⚠️ 支撑强度说明:讨论中曾以「BYOK 是穿过付费墙的洞」为决定性理由——该理由随「暂不收费」作废。 现有理由为维护成本级,弱一档但足够。将来若成本失控,泄压应走配额闸而非 BYOK。

D3. 砍掉 DeepL,翻译只走 LLM

DeepL 是非 LLM 的专用翻译 API,_shared/llm.ts 覆盖不了。理由:

  1. 供应商账单从 1 条变 2 条(free tier 500k 字符/月立刻爆,要上 Pro)
  2. 记账维度打架llm.ts + governance.ts 整套围绕 token 建,DeepL 按字符计费
  3. 它挡住差异化:DeepL 做不了「按 CEFR 简化的译文」「保留生词原文只译周边」—— 而那才是「翻译不只是翻译,是学习辅助」的落点(本 plan 不做,但把路留出来)

实测:call_chat_api 的注释写「shared by translation and analysis commands」是陈旧的—— 全仓只有 translate.rs 自己在用(analysis.rsapi_key 字样)。删除面干净,不牵连别处。

D4. 不做质量分档(gate features, not quality)

曾考虑「免费版给便宜档模型 / 低质译文,Pro 给高质量」。否决

用户不知道自己在体验哪一档。看到糙译文得出的结论是「这产品翻译不行」,不是「我该升级了」。 降质量档伤品牌,换不来转化——一个觉得产品不行的人不会付费给同一个产品。

同一条否掉了「免费 web-speech / Pro Azure Neural」的静默降级—— 且经 D4b 后 web-speech 已不再是用户可见档位,这个分档方案彻底出局(不只是"不能静默")。

修正原则:锁功能,不锁质量。若某物天然连续、非分档不可,分档必须在使用点上可见并可归因, 让用户听到/看到的是自己选的那档。没标注的降级 = 品牌损伤;标注了的降级 = 可接受。

D4b. 语音更进一步:删掉「系统语音」这个用户可选项(2026-08-02 追加)

D4 原本的解法是「保留两档但具名标注」。用户判断更彻底:一个等同于低质量的选项,宁可不给—— 用户不会因为选项有名字就正确归因,只会觉得产品发音难听。

⚠️ 实测发现,问题比"多一个选项"严重tts.ts:53 / tts.js:34 / SpeechSettings.tsx:15默认值全是 'web-speech',而 tts.ts:9-10 注释写的「Azure 验证 OK 后切默认为 azure」从没执行。 → 今天绝大多数用户听到的默认就是系统语音。D4 那条病在产品里已经犯了, 形式不是"分档低质"而是"默认低质"。本条顺带把默认拉到 Azure,是整个 plan 里最便宜的实质提升。

三件事必须分开,只有第一件被删

是什么处置
① 设置项(常态档位)SpeechSettings.tsx 的 provider select
② 失败 fallbacktts.ts:157-166 Azure 报错 → web-speech + 一次性 toast保留
③ 配额超限降级(WP3)超限落 web-speech 不静音保留(依赖 ②)

② 为何保留:现有 toast「TTS 暂时不可用,已切换为系统朗读」(locales/zh/toast.ts:13是一次标注过的降级,归因正确——用户知道这是异常态兜底,不会当成产品水平,符合 D4 修正原则。 且它是 R1(大陆网络下 Azure/edge 不可达)的唯一兜底,删了 = 彻底没声音。

记录另一个自洽选项(未采纳):Azure 失败直接报错不出声。更符合「宁可没有也不要低质」的极端解读, 但在网络不稳环境下等于功能随机消失。若 R1 实测严重,可重新评估。

D5. 全页双语改段落点按(本轮最重要的产品决策)

问题不在「翻译」,在粒度和默认态

性质
整页 + 常开替代品——你从没试着自己读
单段 + 按需脚手架——你试过了,然后对答案

产品里已有的选区翻译和句子工作台形状本来就是对的(先选中 = 先试过 = 遇到具体困难), 唯一形状错的就是全页双语这一个模式。

改成段落点按后:

  • 救援职能完整保留(卡住随时能解,避免弃读)
  • 提取努力恢复(得先读一遍才知道自己卡住)
  • 产品其余功能不被关掉(绝大多数段落仍在高亮 / 可查词状态)
  • 成本正比于真实困难,不正比于文章长度 → 翻译从"最陡的成本曲线"变成账本上最不起眼的一项

这是「翻译特例」最彻底的解法:不是用配额或缓存去驯服它,是让它不再具有那个形状。 工程手段处理特例,产品决策消灭特例。

D6. 配额闸建成反滥用档,不是转化杠杆

「暂时免费,收费点看产品力」既定 → ③ 的阈值设在典型用量的 5–10 倍(正常用户永远撞不到), 作用是挡脚本刷子和异常账号。同一套机制、同一张计数器表,将来要收费改配置不改代码(见 §九)。

D7. 直接查 llm_call_log 做配额判定

governance.ts::isBudgetExceeded 那套聚合对 cron 批处理够用,对「每次用户点击前跑一次」是 O(n) 扫表。且 llm_call_log 是 service_role only,用户看不到自己的用量。 → 需要独立的 user_quota_usage 计数器表 + 原子 increment RPC,顺带隔离运营数据与用户可见数据。


四、WP 拆解

WP0 —— edge 读用户 JWT(RB 可独立推进,不阻塞 RVH

客户端supabase.rs4 处 edge 调用,Bearer 从 anon_key 换成用户 access_tokenapikey header 仍留 anon_key,edge 网关需要):

端点换 JWT?
101/functions/v1/lookup-or-fetch-word
231/rest/v1/vocabulary(缓冲池)不动
348/functions/v1/disambiguate-sense
575/functions/v1/judge-phrases-batch
732/functions/v1/generate-example

⚠️ 231 行是 PostgREST 不是 edge,读的是跨用户共享的 vocabulary 缓冲池。 vocabulary-buffer.sql:12 明写「RLS 策略未在本次导出范围内」——仓里没有它的策略定义, 只知道现在用 anon key 读得通。顺手换 JWT = 拿一个无文档的 RLS 策略赌运气。保持 anon key。

⚠️ 必须同时做的防回归(F2)anon_key 永不过期access_token 会过期settings.auth_expires_at)。sync 路径有主动续命(sync/mod.rs:39),edge 调用路径没有, 而 edge 调用是用户随手触发的(双击查词 / 点拆解),完全可能落在 token 过期时刻。 直接换 = 自己种下「查词/拆解偶发 401 失败」的新 bug。

修法(现成积木):抽 ensure_fresh_token(conn) -> CommandResult<String> —— 沿 auth_get_session:455expires_at < now + 60 判定,过期则调 auth_refresh_token:464。 4 处 edge 调用统一经它取 token。未登录 / 刷新失败 → 回落 anon_key(降级为匿名,不阻断功能)。

服务端_shared/auth.ts::getUserId(req) —— 解 Authorization JWT 取 sub, 无 sub(anon token)返回 null不拒绝,只标记。

关键性质:这是加法不是替换。RVH 继续送 anon key 会被记为匿名,不会挂。 「必须新会话」的阻塞点因此推迟到 WP3 阶段 2(真正开始拒绝匿名请求时)。

验收

  1. llm_call_log 新写入行中,RB 触发的 user_id 非空;RVH 触发的为空且功能正常
  2. token 过期场景:手工把 auth_expires_at 改成过去时刻 → 双击查词仍成功(走了续命)
  3. 缓冲池 L2 命中路径(231 行)不受影响——查一个预装库外、缓冲池内的词仍走 L2 而非 L3

WP1 —— 账本补全

  1. llm_call_loguser_id uuid(Dashboard 手动 ALTER … ADD COLUMN IF NOT EXISTS先于新 edge 上线);LLMCallOptionsuserId?logCall 写入
  2. tts-synthesize 纳入记账:新建 tts_call_log(或复用 llm_call_logkind 列—— 倾向新表,因为计量单位是字符不是 token,混在一起会污染 governance 的 token 聚合)。 记 user_id / voice / char_count / cost_usd / latency_ms / status
  3. issue-speech-token 记发放:记 user_id / issued_at明确承认:这是发放日志不是 用量日志(token 出门后不可计量),它的作用是配合 WP3 的频次限制,不是精确计费

保留策略:沿 llm_call_log 的 90 天 cron 清理(observability-tables.sql 末尾注释)。

隐私性质变更llm_call_loguser_id 后从纯运营表变成含个人关联数据的表。 存 UUID 非 email + 90 天保留 + RLS 仍 service_role only → 可接受。须同步更新隐私政策文案。

WP2 —— user_quota_usage 计数器表

sql
create table if not exists user_quota_usage (
    user_id    uuid not null,        -- 匿名请求用 nil UUID 哨兵,见下
    period     text not null,        -- 'YYYY-MM'(UTC 月)
    meter      text not null,        -- 'llm_calls' | 'translate_paragraphs' | 'tts_chars' | 'speech_tokens'
    units      bigint not null default 0,
    cost_usd   numeric(12,6) not null default 0,
    updated_at timestamptz default now(),
    primary key (user_id, period, meter)
);
  • RLS:select 策略 auth.uid() = user_id(用户读自己的用量);写只走 service_role

    可行性已验证:sync-tables.sql:126 等处全用 auth.uid() = user_id,且 SyncConfig 已携带 access_tokensync/common.rs:14)→ 客户端拿用户 JWT 读 PostgREST 的通路是现成的、线上在跑的

  • 原子累加走 rpc('bump_quota', …)insert … on conflict do update set units = units + excluded.units
  • 计数器由 edge 侧在调用成功后累加(失败不计费,与 llm_call_log 的 status 区分开)

⚠️ 匿名桶(F3):WP3 阶段 1 的「匿名走全局共享桶」与 user_id uuid not null 冲突—— 匿名没有 user_id 写不进去。用 nil UUID 哨兵 '00000000-0000-0000-0000-000000000000' 作为全局匿名桶的 key。它永远不会等于任何 auth.uid(),所以 RLS 天然不会把它暴露给任何用户。 阶段 2 开始拒绝匿名后,这行会停止增长,可作为「RVH 是否已迁完」的观测指标。

验收:① 同一用户连续触发 N 次 LLM 功能,units 恰好 +N;② 失败的调用不计数; ③ 用户 A 登录时查不到用户 B 的行(RLS 生效);④ RVH(anon)触发的计入 nil UUID 行。

WP3 —— 反滥用配额闸

判定位置:各 user-facing edge function 入口,调 LLM/TTS 之前共享实现_shared/quota.ts::checkQuota(userId, meter, cost){ allowed, used, limit }

阈值(进 recommend_config 同风格的配置表,可 admin 调)

⚠️ 下表的 llm_calls 假设已被实测推翻,动工前必须重算——见本节末「实测校准」。

meter典型重度用量/月闸值(≈8x)对应 COGS
llm_calls(见下方校准)~500 次4,000 次$0.80
translate_paragraphs(段落点按)~50 段2,000 段$0.10
tts_chars~18,000 字符150,000 字符$2.40
speech_tokens(发放频次)~100 次1,000 次/月 + 20 次/小时

实测校准(2026-08-02,WP0/WP1/WP2 上线后的真实数据)

单用户 13 分钟正常使用(读页面 + 双击查词几次 + 开一次 Stats)产生 12 次 LLM 调用 / 9968 tokens / $0.0015

functiontokens触发方式
judge-phrases-batch67755(78%被动——浏览页面自动跑短语高亮判定
disambiguate-sense51495主动(双击查词)
reading-report1718主动(开 Stats)

结论一:原阈值太紧。 按此速率外推(每天读 1 小时 × 30 天)≈ 1650 次/月, 而原定 4,000 闸值只有 2.4 倍余量,不是设计意图的 8 倍。作为「正常用户永远撞不到」 的反滥用闸,余量不足。

结论二(更重要):judge-phrases-batch 不该占用户的动作额度。 它是被动触发的——用户没做任何操作,只是在读,短语自动高亮就在后台跑, 且吃掉 78% 的 token。把它计进 llm_calls 意味着用户会因为「读得多」而撞墙, 直接违反 §九 那条约束「墙不能压到核心阅读闭环」——短语自动高亮属于阅读闭环本身 (与 CEFR 高亮同类),不是「AI 增强层」。

修正方案(WP3 动工时采用):拆成两个 meter。

meter含义建议闸值/月依据
llm_calls主动:拆解 / 消歧 / 例句 / 指代 / 选区译 / 周报4,000 次实测主动部分 6 次/13min → ~800 次/月,5 倍余量
llm_calls_passive被动:judge-phrases-batch 等后台增强30,000 次实测 6 次/13min → ~1650 次/月,18 倍余量;且超限只静默降级(停自动高亮),不打扰用户

配额语义上「用户主动要的」与「app 后台替他做的」本就该分开——用户对前者有控制权, 对后者没有,不该为后者承担撞墙后果。

样本很小(1 用户 / 1 次会话 / 13 分钟),数字是方向性的不是精确的。 WP3 动工前应看累积几天的 llm_call_log 再定稿。

阶段 1 as-built(2026-08-02,commit 7df8b6a

决策落点
阈值刻意宽松 + 存 quota_configllm_calls 20000 / passive 60000 / translate 20000 / tts_chars 500000 / speech_tokens 2000。收紧 = 一条 SQL UPDATE
主动/被动分 meterjudge-phrases-batch → llm_calls_passive,其余 → llm_calls
超限统一 HTTP 429让 graceful 型客户端自动静默降级、传播型自动明确报错,省掉改 6 处客户端
一律 fail-open配额查询自身出错就放行——不值得为严密而让一次 DB 故障打掉全部 AI
TTS 超限不静音429 → tts.ts catch 自动落 web-speech(带 toast 标注)
speech token 小时突发闸60 次/时,硬编码不进配置表——它拦的形态与用量档位无关,跟着月阈值调易被误放大
config 读表 60s 进程内缓存摊薄每次调用前的读表开销

实测:429 payload 正确({error,meter,used,limit});被拦请求未产生 llm_call_log 行 —— 真的没花钱(这是本 WP 的全部意义);阈值恢复后照常放行。

未做:80% 提前提示的 UI(需客户端读用量 + 界面承载;当前阈值正常用户撞不到, 是死路径。checkQuota 已返回 warn 字段,接线时现成)。

匿名请求(无 sub)的处理,分两阶段

  • 阶段 1(RVH 迁移前):无 sub → 走一个全局共享桶(保守阈值),不拒绝。RVH 不受影响
  • 阶段 2(RVH 迁移后):无 sub拒绝。此时 anon key 不再是"烧钱凭证"

超限行为——降级不断供

场景表现
用户主动点的(翻译/拆解/例句)明确告知,内联在触发它的面板/弹窗里。静默失败会被归因成"这软件坏了"
后台增强(短语高亮 / CEFR 推断 / 推荐链路)静默跳过,不打扰
TTS 超限自动落 web-speech(已有 fallback 通路,tts.js:142),不静音。⚠️ web-speech 自 WP4e 起仅作内部兜底,不再是用户可选项(D4b)

80% 提示一次,别撞墙。默认不常驻显示计数(常驻计数会让用户天天做配给 = 变相的强制机制, 与「学习自然发生」冲突);想看的人在设置里能查。

⚠️ content webview 遮挡:全页/段落翻译的错误提示必须由 content-script 自己画 (现状 showTranslationError 是对的),不能改从 React 侧弹——会被 content webview 挡住 (memory: content webview 遮挡主 webview 浮层)。

WP4 —— 翻译收口

WP4a. 新建 translate-batch edge function

  • 入参 { texts: string[], target_lang },复用 callLLMjsonMode / 分隔符解析沿用 translate_chat 的 prompt 形状)
  • 入口过 checkQuota('translate_paragraphs', texts.length)
  • translation_cachetext_hash / target_lang / model / translated / hit_count): 翻译是唯一输出与用户无关、可跨用户共享的功能(其余 9 个全是 per-user 语境绑定)。 命中不计配额、不计费。优先级:段落点按落地后这项收益变小,可推后到 WP4 收尾再做

WP4b. 全页双语退役 —— 采方案 B(F4 已定,2026-08-02)

决策什么都不加,只删全页双语;「读不懂某段」的救援职能交给已有的选区翻译toolbar.js:174,选中一段 → 点工具栏翻译)。

为何不做「段落 hover gutter 图标」(方案 A)

  • 选区翻译已经能做这件事,A 的增量价值只是把「拖选整段 + 点按钮」降到「hover + 点图标」
  • 拖选的那点摩擦恰好是想要的——D5 的核心论证是「先试过再对答案」, A 等于在为一个刚论证过「不该太顺手」的功能优化顺手度,自相矛盾
  • 单击段落触发这条路本来就走不通:会与双击查词、拖选出选区工具栏打架

改造内容

  • 删除 bilingual.js 整个模块
  • 连带删除:toggle_bilingual_modetranslate.rs)/ __RB_TOGGLE_BILINGUAL / report_bilingual_mode / 工具栏「双语」入口 / bilingual_enabled setting 读写 / injectTranslation / rb-translation 相关样式 / bilingual.* i18n strings
  • 不动:选区工具栏的翻译按钮(toolbar.js:103/174)、SentenceWorkbench 的译 lens
  • ⚠️ build:cs 必跑(memory: content-script 是打包产物,tauri dev 不跑打包器)

连锁影响

  • WP4c 的调用点从 3 个减为 2 个(选区 + 句子工作台)
  • WP4a 的 translate-batch 入参规模随之变小(选区最多一段), translation_cache 的收益进一步下降 → 确定推后,不在本 plan 实现
  • translate_paragraphs 配额 meter 仍保留(选区翻译按段计),阈值可下调
  • R4(用户习惯变更)仍需一次性说明

WP4c. 三个调用点改走 edgebilingual.js(改造后的段落译)/ toolbar.js:174(选区)/ SentenceWorkbench.tsx(句子) 统一改调 translate-batch

WP4d. 删除 —— ✅ 已完成(2026-08-02)

⚠️ 原定「等一个版本周期」的硬序(§R2)在执行前被证伪,遂提前执行。 R2 的理由是「删早了没有回退路径」,但动工前核对发现旧路径在运行时已不可达translateParagraphscommands.ts零调用方;content-script 里 translateProvider / translateApiKey / translateCustomEndpoint / translateCustomModel 四个 state 字段只写不读(唯一被读的是 translateTargetLang);bilingual.js 已随 WP4b 删除。 留着代码并不构成回退路径——真要回退得重新接一个调用方,与 revert 删除 commit 等价。 反而有正在发生的成本:设置面板仍在展示 provider 下拉 + sk-... 密码框, 用户填进去的 key 静默无效。经用户确认后整体执行。

  • translate.rs:删 translate_deepl / DeepLResponse / DeepLTranslation / ChatRequest / ChatMessage / ChatResponse / ChatChoice / ChatMessageResp / call_chat_api / translate_chat / translate_paragraphs + lib.rs 注册。 文件 373 → 130 行。emit_translation_* 保留(与翻译 key 无关)
  • commands.ts:删 translateParagraphs wrapper
  • TranslationSettings.tsx:删 provider 下拉 + API key 输入框 + custom endpoint/model + 随之无用的 updateSettingDebounced / useCallback / useRef / setSetting import。 127 → 41 行,只剩目标语言 select(translate_target_lang 保留——仍是用户偏好)
  • content-script/features/settings.jsBATCH_KEYS 5 → 1 + 对应 5 处赋值 → 1
  • strings/:删 9 个孤儿 key(translateProvider(Aria) / providerCustom / apiKey(Aria) / customEndpoint(Aria) / customModel(Aria))× {schema, zh, en}; translationSubtitle 文案改写(旧文案「整页 / 划词翻译的 AI 服务配置」两处失实: 整页双语已随 WP4b 删除、也不再有服务配置)
  • settings 表存量 translate_api_key 行:留着不删(用户数据,无害;不值得为它开新迁移)

验证cargo check ✅ · pnpm build ✅(含 build:cs 重打包,实测 bundle 内 4 个 被删 key 归零、translate_target_lang 保留)· check:no-inline-zh ✅ · 全仓无悬挂引用

WP4e —— 删「系统语音」选项 + 默认切 Azure(D4b)

独立于 WP0-WP4d,可先做——不依赖 JWT / 配额 / edge 改造,约 20 行改动。

⚠️ 关键:不能只删 UI。 只删 select 不动读取逻辑的话,存量用户里显式存过 tts_provider = 'web-speech' 的人会永远卡在系统语音,且再也没有 UI 能改回来。 必须连读取一起删——既然没有选项,就不该再读这个 setting。

文件改动
src/components/settings/SpeechSettings.tsx删 provider select 整行(41-50)+ TTSProvider 类型/state;voice 选择器的 provider === 'azure' 守卫去掉(恒真)
src/lib/tts.tsloadSettings 不再读 tts_providerspeak() 去掉 if (provider === 'web-speech') 分支(152-155),直接走 azure + 保留 catch fallback(157-166);更新 9-13 行注释("Phase 1 后切默认"已过期)
src-tauri/src/content-script/features/tts.js同构改动(34 行默认 + provider 分支);⚠️ 改完必跑 build:cs
src/lib/strings/panels/settings.ts + localesspeechProvider / speechProviderAria / speechProviderWebSpeech / speechProviderAzure(确认无其它引用后)

保留不动TTSProvider 类型、SpeakOptions.forceProviderspeakWithWebSpeech()ttsAzureFallback toast —— fallback 路径仍需要它们。 存量 tts_provider settings 行:留着不删(无害,且不再被读;不值得为它开新迁移)。

验收:全新装 + 存量库(含 tts_provider='web-speech' 的)都走 Azure; 断网 / Azure 不可达时仍能出声并弹一次 fallback toast。

WP5 —— 收尾

  • docs/database-schema.mduser_quota_usage / tts_call_log / translation_cache
  • supabase/sql/observability-tables.sql 补新表 DDL(沿现有「Dashboard 手动执行」约定)
  • backlog 该条改「已完成」+ 修正第 237–238 行的 anon key 依赖注记(见 §六)
  • /edge-deploy 部署 translate-batch + 改动过的 function
  • CHANGELOG

五、成本基线(写给未来的自己)

deepseek-v4-flashllm.ts PRICING:$0.14 / $0.28 per 1M):

单价重度用户/月
段落翻译(133 tok in + 120 tok out)~$0.000052 / 段50 段 → $0.003
轻 LLM(拆解/消歧/例句/指代/周报)~$0.0002 / 次500 次 → $0.10
Azure TTS($16/1M 字符)~$0.00096 / 次(60 字符)300 次 → $0.29
跟读评分(Azure,~$1/小时音频)100 次×5s → $0.14
合计≈ $0.53 / 月 / 重度用户

成本敏感度:换 claude-haiku-4-5($1/$5)LLM 部分是 14 倍。 因此记账记 token(已在记),配额按次数,换算比放配置表——换 provider 改配置不改产品文案。


六、跨端 / RVH 协调

WP是否波及 RVH说明
WP0❌ 不波及anon token 无 sub → 记匿名,功能正常。RB 可独立推进
WP1/WP2纯服务端加表加列,additive
WP3 阶段 1匿名走全局桶,不拒绝
WP3 阶段 2不波及(原判断有误,见下)RVH 不调任何带闸 function
WP4翻译是 RB-only 功能(RVH 无浏览/阅读)

RVH 依赖核对(2026-08-02 实测,推翻了本 plan 早先的判断

早先本节写「WP3 阶段 2 阻塞于 RVH JWT 迁移」。那是从 backlog 继承的假设,从未验证。 实际查 ~/reading_vocab_helper 后:

核对项结果证据
RVH 调哪些 edge function只有 lookup-or-fetch-wordlib/features/vocabulary/data/services/supabase_vocabulary_service.dart:111,322_client.functions.invoke
RVH 是否调那 9 个带闸 function一个都不调逐个 grep 全 0 命中
lookup-or-fetch-word 是否带闸,且不调 LLMcheckQuota/getUserId/callLLM 均 0
部署后匿名流量构成只有冒烟 curlWP1 上线后仅 3 条 translate-batch,全是测试

阶段 2 若实现为「9 个带闸 function 拒绝无 sub」,RVH 完全不受影响,RB 单端即可推进。

未能验证的一点supabase_flutter ^2.5.0functions.invoke 是否自动携带用户 JWT。 本机无 RVH 的 pub-cache,读不到 SDK 源码。强先验是「会带」(SDK 把 session token 传播到 各子客户端),但未实测,不作结论。这一条不影响上面的判断——RVH 不碰带闸 function, 送什么都不会被拒;它只影响「RVH 的查词能否归到人头上」这个锦上添花的问题。

🆕 本次核对新发现的缺口:3 个 edge function 完全没有准入

lookup-or-fetch-word / google-books-proxy / gutenberg-proxy —— checkQuotagetUserId 均为 0。它们不调 LLM(lookup-or-fetch-word 走 Free Dictionary),所以不烧 LLM 钱,但仍是拿 anon key 就能无限打的公开端点:消耗 Supabase 调用额度 + 外部 API 配额 + DB 写入。

三者的处置各不相同(2026-08-02 逐个实测三端消费者):

Function消费者鉴权方式处置
gutenberg-proxy三端零消费者(RVH 只有 app_config.dart:191-195 一个 getter 定义 + 注释,全仓无调用;RB / admin 无)删除,不是加闸。留着 = 没人负责且能被 anon key 打的公开端点。顺手清 RVH 那个死 getter
lookup-or-fetch-wordRVH(supabase_vocabulary_service.dart:111,322,走 SDK _client.functions.invokeSDK 托管 —— 很可能已自动带用户 JWT(未实测)加闸前先实测:登录态打一次,看服务端能否解出 sub。若已带,加闸零成本
google-books-proxy数据流不可达 = 事实上的死代码(详见下)硬编码 anon key(google_books_datasource_impl.dart:90,128),但从不执行删除(须先确认野外无旧版 RVH)

google-books-proxy 的可达性追踪(2026-08-02,第三次核对才查对): 页面 CreateBookPage 本身可达routes.dart:95 + scan_page.dart:109 + filter_confirmation_page.dart:216),但页内的封面下载路径永不触发_googleBooksCoverUrl 声明即 null,全仓只有 :134 / :149 两处把它清成 null没有任何地方赋非 nullgrep "googleBooksCoverUrl\s*=" | grep -v "= null" 空)。 两个下载分支(:219 / :251)都 gate 在 != null 上 → 永不进入 → coverImageDownloaderProvider 从不触发。googleBooksRepositoryProvider / readingNoteSearchProvider 另有零消费方。封面搜索的 UI 入口已被拆除,只剩管道。

⚠️ 教训:前两次核对分别查了「符号是否被引用」和「页面是否可达」,都在半路收手—— 符号有引用、页面有路由,看起来都像"在用"。只有追到数据流才能判定可达性。 查到一半就停,比没查更容易给出自信的错误结论。

修正后的结论:三个裸 function 只有一个是活的

Function真实状态处置需要 RVH 改代码?
gutenberg-proxy三端零引用删除❌(仅清死 getter,RVH 自行处理)
google-books-proxy数据流不可达删除❌(RVH 侧是自己的死代码清理)
lookup-or-fetch-word真的在用(RVH 查词降级)加闸❌(走 SDK,先实测是否已带 JWT)

→ 本 plan 剩余工作,没有一项必须改 RVH 代码。

已删除(2026-08-02):用户确认 RVH 从未对外分发,故无旧版兼容顾虑。 supabase functions delete 删线上 + git rm 删源码,线上 function 从 14 → 12。 同批修正了 supabase/README.md 的三处既有漂移(列了不存在的 explain-phrase、 漏了 judge-phrases-batchtranslate-batch)+ CLAUDE.md 的 function 计数 (曾同时写着"10 个"和"14 个")+ edge-deploy skill 矩阵。

RVH 侧残留死代码google_books_* datasource/repository/model、 _googleBooksCoverUrl 字段与两处不可达分支、app_config.dart 两个 getter、 4 个 provider)由 RVH 自行清理 —— 不阻塞 RB,服务端删掉后那些代码只是永远走不通。

对 backlog「🔴 轮换 Supabase anon key」的更正

backlog 第 237–238 行写「若启动本条,anon key 轮换升级为前置条件」——方向反了

WP3 阶段 2 落地后,光有 anon key(无 sub打不出 LLM 调用,它就不再是"烧钱凭证"。 攻击者得注册真账号,而那被 per-user 配额封顶且可封号。 → 真正的准入控制是 per-user 配额,不是 key 保密;anon key 轮换回落到「例行安全卫生」级别。 残留敞口是批量注册刷号,靠 Supabase email 确认 + 平台 rate limit 兜。


七、风险登记

#风险处置
R1大陆网络可达性:删掉本地直连后,翻译对 Supabase edge 变成硬依赖不在本 plan 解决。这是全局问题(sync + 另外 9 个功能 + TTS 全都依赖),用 BYOK 局部补翻译一条属自欺。正解是整体可达性策略(自定义域名 / 边缘节点),另开条目
R2WP4d 删早了没有回退路径硬序:WP4a-c 实机验证通过 + 至少一个版本周期后再执行 WP4d 前提证伪(2026-08-02):旧路径运行时不可达(translateParagraphs 零调用方 / 4 个设置字段只写不读),保留它并不构成回退路径 —— 回退成本与 revert 删除 commit 相同。WP4d 提前执行,详见 §WP4d
R3llm_call_loguser_id 后隐私性质变更UUID 非 email + 90 天保留 + RLS service_role only;同步更新隐私政策文案
R4段落点按改变用户既有习惯(现有用户开着全页双语)首次进入时一次性说明;bilingual_enabled 设置项语义变更需兼容处理
R5Supabase Dashboard 手动 DDL 漏执行 → PostgREST 拒未知列 payload沿 v23/v24 先例:DDL 必须先于新客户端/新 edge 上线,在 WP1/WP2 开头显式列为第一步
R6跟读评分(issue-speech-token)当前质量不佳本 plan 不处理功能存废(独立产品判断)。这里只做记发放 + 频次限制

八、明确不做

  • ❌ 定价 / 支付集成 / 订阅 entitlement(见 §九)
  • ❌ BYOK(D2)
  • ❌ DeepL(D3)
  • ❌ 质量分档 / 静默降级(D4)
  • ❌ 跟读评分的功能存废判断(R6)
  • ❌ 大陆网络可达性(R1)
  • ❌ admin 界面管 key(D1);admin 侧只在配额阈值进配置表后加个编辑入口(可选,非必须)
  • translate.rstoggle_bilingual_mode / emit_translation_*(与 key 无关,保留)

九、未来接入点:如果将来要收费

当前决定:暂时全免费,收费点看产品力(帮助用户积累词汇),不建在用量额度上。

本 plan 的产出让「将来要收费」变成改配置不改代码

  1. user_quota_usage 已按 (user_id, period, meter) 计量 → 直接就是账单底座
  2. checkQuota 的阈值来自配置表 → 加一列 tier,按用户档位取不同阈值即可
  3. 需要新增的只有:用户表加 entitlement 字段 + 判定函数 + 支付通道

若真到那一天,本轮讨论沉淀的三条约束仍然成立

  • 锁功能,不锁质量(D4)——降质量伤品牌、换不来转化
  • 墙不能压到核心阅读闭环(CEFR 高亮 / 查词 / 词汇追踪 / SRS / 统计全走本地预装库, 本来就不烧钱,可以真无限)——否则「学习自然发生」的定位断裂
  • 额度默认不常驻显示,只在 80% / 100% 各提示一次——常驻计数让用户天天做配给, 是变相的强制机制

内测期若需要小范围收费验证:先只做 entitlement 字段 + 手工开通,不接支付通道。 用户量小的阶段,手工开通零成本,能避免支付集成把范围撑爆。