主题
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_token (AuthHttpClient每次请求现取accessToken ?? anon_key),RVH 无需改代码; 且 RVH 有硬 auth gate,未登录不可达任何功能页 → anon 分支在 RVH 不可达。 加闸判据须为「无sub才拒」(RVH 送标准role=authenticatedtoken,无自定义 claim)。加闸拆两步(2026-08-03)——因为上述结论是客户端 wire 探针得出的, RVH 自己写明「服务端观测到的 userId:未观测」。直接上 401 的风险 = RVH sync 期 补词静默挂掉。
- 第一步 ✅ 已部署并实测(2026-08-03):anon key 打
lookup-or-fetch-word返 200(确认不拒绝),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_limitDDL(默认 30000)。 实测:anon+合法 body → 401authentication_required;真实用户 token → 200 + 完整释义; 畸形 body 仍 400(校验在闸前);401 不计配额(闸在 bumpQuota 之前)。上游硬化(2026-08-03,加闸之后的独立一轮):用户实测「第一次双击没释义、第二次才有」 引出的。根因不是 RB —— 是
lookup-or-fetch-word把 404(词典里真没有) 与 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 修正)·92ed591(WP0 补丁:补齐漏掉的 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:239PostgREST 缓冲池,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-batchedge function、 两个调用点改走 edge、全页双语整块退役。实测 3 段输入 → 3 段译文顺序对齐、translate_paragraphsunits=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.rs是 4 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_key(TranslationSettings.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 行),不带用户 JWTllm_call_log(observability-tables.sql:16)无user_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),早做早收益。
价值
- 消除"必须填 key"的摩擦 → 翻译从死功能变成所有用户可用的功能
- 花的每一分钱能定位到人 → 异常花费可诊断、可封堵,而不是只能看总额干瞪眼
- 段落点按同时是产品修复 → CEFR 高亮 / 双击查词 / cloze 语境池在阅读全程保持在线, 直接服务「帮助用户积累词汇」这个价值中心
- 设计对商业模式中立 → 在「收费」和「不收费」两种前提下是同一个形状,将来往哪走都不返工
二、现状核对(2026-08-02 实测)
LLM / TTS 功能清单与持 key 位置
| 功能 | 落点 | key 在哪 | 记账 |
|---|---|---|---|
| breakdown-sentence / resolve-context / disambiguate-sense / generate-example / lookup-or-fetch-word / judge-phrases-batch / reading-report | edge | Function Secrets | ✅ llm_call_log |
| discover-sites / analyze-articles(推荐链路) | edge | Function Secrets | ✅ + governance 预算闸 |
| 翻译(3 个调用点,见下) | Rust 本地 | settings 表(用户填) | ❌ 无 |
| tts-synthesize | edge 代理 | Function Secrets | ❌ 无记账 |
| issue-speech-token | edge 发 token | Function 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:65 是 isInViewport(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=anon,无sub)→ ① 是加法不是替换 _shared/llm.ts::callLLM已统一记 token / cost / latency / statusrecommend_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 条变 2 条(free tier 500k 字符/月立刻爆,要上 Pro)
- 记账维度打架:
llm.ts+governance.ts整套围绕 token 建,DeepL 按字符计费 - 它挡住差异化:DeepL 做不了「按 CEFR 简化的译文」「保留生词原文只译周边」—— 而那才是「翻译不只是翻译,是学习辅助」的落点(本 plan 不做,但把路留出来)
实测:
call_chat_api的注释写「shared by translation and analysis commands」是陈旧的—— 全仓只有translate.rs自己在用(analysis.rs无api_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 | 删 |
| ② 失败 fallback | tts.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.rs 的 4 处 edge 调用,Bearer 从 anon_key 换成用户 access_token (apikey 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:455 的 expires_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(真正开始拒绝匿名请求时)。
验收:
llm_call_log新写入行中,RB 触发的user_id非空;RVH 触发的为空且功能正常- token 过期场景:手工把
auth_expires_at改成过去时刻 → 双击查词仍成功(走了续命) - 缓冲池 L2 命中路径(231 行)不受影响——查一个预装库外、缓冲池内的词仍走 L2 而非 L3
WP1 —— 账本补全
llm_call_log加user_id uuid(Dashboard 手动ALTER … ADD COLUMN IF NOT EXISTS, 先于新 edge 上线);LLMCallOptions加userId?,logCall写入tts-synthesize纳入记账:新建tts_call_log(或复用llm_call_log加kind列—— 倾向新表,因为计量单位是字符不是 token,混在一起会污染governance的 token 聚合)。 记user_id/voice/char_count/cost_usd/latency_ms/statusissue-speech-token记发放:记user_id/issued_at。明确承认:这是发放日志不是 用量日志(token 出门后不可计量),它的作用是配合 WP3 的频次限制,不是精确计费
保留策略:沿 llm_call_log 的 90 天 cron 清理(observability-tables.sql 末尾注释)。
隐私性质变更:
llm_call_log加user_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_token(sync/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 | ~ | ||
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:
| function | 次 | tokens | 触发方式 |
|---|---|---|---|
judge-phrases-batch | 6 | 7755(78%) | 被动——浏览页面自动跑短语高亮判定 |
disambiguate-sense | 5 | 1495 | 主动(双击查词) |
reading-report | 1 | 718 | 主动(开 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_config 表 | llm_calls 20000 / passive 60000 / translate 20000 / tts_chars 500000 / speech_tokens 2000。收紧 = 一条 SQL UPDATE |
| 主动/被动分 meter | judge-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 },复用callLLM(jsonMode/ 分隔符解析沿用translate_chat的 prompt 形状) - 入口过
checkQuota('translate_paragraphs', texts.length) translation_cache表(text_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_mode(translate.rs)/__RB_TOGGLE_BILINGUAL/report_bilingual_mode/ 工具栏「双语」入口 /bilingual_enabledsetting 读写 /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 的理由是「删早了没有回退路径」,但动工前核对发现旧路径在运行时已不可达:
translateParagraphs(commands.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:删translateParagraphswrapperTranslationSettings.tsx:删 provider 下拉 + API key 输入框 + custom endpoint/model + 随之无用的updateSettingDebounced/useCallback/useRef/setSettingimport。 127 → 41 行,只剩目标语言 select(translate_target_lang保留——仍是用户偏好)content-script/features/settings.js:BATCH_KEYS5 → 1 + 对应 5 处赋值 → 1strings/:删 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.ts | loadSettings 不再读 tts_provider;speak() 去掉 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 + locales | 删 speechProvider / speechProviderAria / speechProviderWebSpeech / speechProviderAzure(确认无其它引用后) |
保留不动:TTSProvider 类型、SpeakOptions.forceProvider、speakWithWebSpeech()、 ttsAzureFallback toast —— fallback 路径仍需要它们。 存量 tts_provider settings 行:留着不删(无害,且不再被读;不值得为它开新迁移)。
验收:全新装 + 存量库(含 tts_provider='web-speech' 的)都走 Azure; 断网 / Azure 不可达时仍能出声并弹一次 fallback toast。
WP5 —— 收尾
docs/database-schema.md补user_quota_usage/tts_call_log/translation_cachesupabase/sql/observability-tables.sql补新表 DDL(沿现有「Dashboard 手动执行」约定)- backlog 该条改「已完成」+ 修正第 237–238 行的 anon key 依赖注记(见 §六)
/edge-deploy部署translate-batch+ 改动过的 function- CHANGELOG
五、成本基线(写给未来的自己)
按 deepseek-v4-flash(llm.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-word | lib/features/vocabulary/data/services/supabase_vocabulary_service.dart:111,322(_client.functions.invoke) |
| RVH 是否调那 9 个带闸 function | 一个都不调 | 逐个 grep 全 0 命中 |
lookup-or-fetch-word 是否带闸 | 否,且不调 LLM | checkQuota/getUserId/callLLM 均 0 |
| 部署后匿名流量构成 | 只有冒烟 curl | WP1 上线后仅 3 条 translate-batch,全是测试 |
→ 阶段 2 若实现为「9 个带闸 function 拒绝无 sub」,RVH 完全不受影响,RB 单端即可推进。
未能验证的一点:
supabase_flutter ^2.5.0的functions.invoke是否自动携带用户 JWT。 本机无 RVH 的 pub-cache,读不到 SDK 源码。强先验是「会带」(SDK 把 session token 传播到 各子客户端),但未实测,不作结论。这一条不影响上面的判断——RVH 不碰带闸 function, 送什么都不会被拒;它只影响「RVH 的查词能否归到人头上」这个锦上添花的问题。
🆕 本次核对新发现的缺口:3 个 edge function 完全没有准入
lookup-or-fetch-word / google-books-proxy / gutenberg-proxy —— checkQuota 与 getUserId 均为 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-word | RVH(supabase_vocabulary_service.dart:111,322,走 SDK _client.functions.invoke) | SDK 托管 —— 很可能已自动带用户 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, 没有任何地方赋非 null(grep "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-batch与translate-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 局部补翻译一条属自欺。正解是整体可达性策略(自定义域名 / 边缘节点),另开条目 |
| R2 | WP4d 删早了没有回退路径 | translateParagraphs 零调用方 / 4 个设置字段只写不读),保留它并不构成回退路径 —— 回退成本与 revert 删除 commit 相同。WP4d 提前执行,详见 §WP4d |
| R3 | llm_call_log 加 user_id 后隐私性质变更 | UUID 非 email + 90 天保留 + RLS service_role only;同步更新隐私政策文案 |
| R4 | 段落点按改变用户既有习惯(现有用户开着全页双语) | 首次进入时一次性说明;bilingual_enabled 设置项语义变更需兼容处理 |
| R5 | Supabase 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.rs的toggle_bilingual_mode/emit_translation_*(与 key 无关,保留)
九、未来接入点:如果将来要收费
当前决定:暂时全免费,收费点看产品力(帮助用户积累词汇),不建在用量额度上。
本 plan 的产出让「将来要收费」变成改配置不改代码:
user_quota_usage已按(user_id, period, meter)计量 → 直接就是账单底座checkQuota的阈值来自配置表 → 加一列tier,按用户档位取不同阈值即可- 需要新增的只有:用户表加
entitlement字段 + 判定函数 + 支付通道
若真到那一天,本轮讨论沉淀的三条约束仍然成立:
- 锁功能,不锁质量(D4)——降质量伤品牌、换不来转化
- 墙不能压到核心阅读闭环(CEFR 高亮 / 查词 / 词汇追踪 / SRS / 统计全走本地预装库, 本来就不烧钱,可以真无限)——否则「学习自然发生」的定位断裂
- 额度默认不常驻显示,只在 80% / 100% 各提示一次——常驻计数让用户天天做配给, 是变相的强制机制
内测期若需要小范围收费验证:先只做 entitlement 字段 + 手工开通,不接支付通道。 用户量小的阶段,手工开通零成本,能避免支付集成把范围撑爆。