主题
数据库设计
权威来源:
src-tauri/assets/sql/schema.sql(v1 冻结 baseline,2026-07-26)+src-tauri/src/db/migrations.rs(当前链 v1+v2+v3,新迁移 v28+;见 §10) Supabase 同步表:supabase/sql/sync-tables.sql(10 张用户表 + 4 张推荐/公共表) 表速览(分组 + 数据流 + 关系图 + 同步/soft-delete 一览):docs/database-tables-overview.md(概念地图,与本文档互补:本文档管列级字典,overview 管概念/流向)
目录
1. 概览
25 张表(22 张已折叠进 v1 冻结 baseline + 冻结后 append-only 新增的 usage_events_local(v28) / word_encounters(v31) / epub_positions(v32);phrase_interaction_log 曾走迁移 v20,已于 v25 DROP 退役),按用途分 5 组:
| 分组 | 表数 | 表名 |
|---|---|---|
| 学习核心 | 2 | vocabulary, learning_entries |
| 阅读笔记 | 5 | reading_notes, reading_pages, domain_prefs, page_annotations, epub_positions |
| 词汇关联 | 4 | word_page_links, word_cloze_contexts, word_personalized_examples, word_encounters |
| 词汇过滤 | 2 | known_words, default_stopwords |
| 内容发现 & 统计 | 11 | settings, recommended_sites, recommended_feeds, recommended_articles, rss_feeds, rss_items, favorite_sites, resource_history, reading_log, pronunciation_attempts, usage_events_local |
同步表(10 张,有 synced_at 列):learning_entries, reading_notes, reading_pages, word_page_links, word_cloze_contexts, known_words, favorite_sites, rss_feeds, domain_prefs, page_annotations
注 1:reference_words 自 2026-04-21 起从同步表移除,定位为纯系统共享表(仅本地,所有用户共用 ~193 条预装专有名词)。 2026-08-31 再降一级:它不再参与任何查询过滤,因此已不算「词汇过滤」表 —— 现在只是一份带
chinese_meaning的专名数据(留给 L-D 的中文兜底)。运行时的专名判据在src-tauri/src/db/vocab_scope.rs。注 2:favorite_sites / rss_feeds / domain_prefs 自 2026-04-21 起加入同步矩阵——三张表带 user_id + 同步列;rss_items 不同步(内容可再生,每端独立拉取,feed 删除时孤儿清理)。
注 3:page_annotations 自 2026-04-22(阶段 D)起加入同步矩阵——带 user_id + 同步列,跨端保留用户在某篇 reading_source 上的翻译/长难句分析结果。
注 4:word_cloze_contexts 自 2026-07-09(migration v24)起加入同步矩阵——此前 local-only(v16-v21)。一词多语境池的 merge 需要 pull 后额外做"按句去重 + 重新封顶 5"(
reconcile_cloze_pool),是其余同步表都不需要的一步,详见 §8。
Soft-delete 表(10 张,有 deleted_at 列):learning_entries, known_words, reference_words, favorite_sites, rss_feeds, domain_prefs, page_annotations, reading_pages + word_page_links(v23 新增,笔记四级删除闭环), word_cloze_contexts(v24 新增)
2. 表关系图
全表关系图 / 数据流全景 / 按用途分组的速览统一见
docs/database-tables-overview.md§6-§7(唯一权威源)。 本文档聚焦列级定义,不再重复维护关系图,避免两份图漂移。
3. 学习核心表
vocabulary — 词库
预装 18,898 条词汇(含 idiom / phrasal_verb 短语;预装库 v18,2026-06-08;实测 SELECT COUNT(*) FROM vocabulary)。来源:src-tauri/assets/lampio_dict.db(与 RVH assets/databases/lampio_dict.db byte-equal 共享,红线 #10;2026-05-14 v10 起切换)。支持用户查词自动扩展(缓冲池 = Supabase vocabulary,预装库外的新词靠 backfill_word_inner 兜底补入)。
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| word | TEXT | PK COLLATE NOCASE | 单词归一形(lemmatize + NFC,写入前必经 lemmatizer::normalize) |
| ipa_pronunciation | TEXT | IPA 音标 | |
| pronunciation_url | TEXT | 发音 URL | |
| primary_cefr_level | TEXT | A1-C2 | |
| pos_definitions | TEXT | JSON:词性 + 中英释义(每 def 含 gloss/examples/synonyms/antonyms;RVH v19 起带 differentiation[{synonym,distinction_en,distinction_zh}] + collocations[{pattern,note_zh}],roadmap ⑤ 近义辨析/搭配) | |
| frequency_rank | INTEGER | 词频排名 | |
| source | TEXT | DEFAULT 'preinstalled' | preinstalled / user_added |
| last_accessed_at | TEXT | 最后查询时间 | |
| etymology | TEXT | 词源 | |
| word_forms | TEXT | 词形变化 | |
| audio_local_path | TEXT | 本地音频路径 | |
| word_family | TEXT | 词族 | |
| cefr_inferred | INTEGER | NOT NULL DEFAULT 0 | CEFR 是否为推断值(RVH v48;SQLite INT 0/1 ↔ Supabase BOOLEAN) |
| cefr_source | TEXT | CEFR 来源:'oxford'/'cefr_j'/'llm'/'freq'/'fallback'/NULL(RVH v49) | |
| word_tags | TEXT | JSON 数组字符串,如 '["proper_noun"]'(RVH v50;正交于 CEFR 难度;短语库后含 idiom/phrasal_verb) | |
| emoji | TEXT | OpenMoji hexcode 大写(如 '1F436'),NULL=无图(migration v14 / RVH v53;不进 Supabase 同步矩阵) | |
| canonical_surface | TEXT | 习语可读引用形(如 'raining cats and dogs'),归一键 word('rain cat and dog')的展示形;NULL=归一无损回退 word(migration v15 / RVH v54)。纯展示,匹配/save 仍只用 word;不进 Supabase 同步矩阵 | |
| created_at | TEXT | NOT NULL | |
| updated_at | TEXT | NOT NULL |
learning_entries — 学习状态(SM-2)
每个用户学过的词一条记录,承载 SM-2 算法状态。
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | TEXT | PK | UUID |
| word | TEXT | FK → vocabulary(word) ON DELETE RESTRICT NOT NULL COLLATE NOCASE | 与 vocabulary.word 直接关联(v9 跟 RVH v51 翻 CASCADE→RESTRICT;rusqlite 路径 FK 默认 OFF,但 plugin/sqlx 路径默认 ON;当前无 DELETE FROM vocabulary 业务路径,差异未触发,翻转价值是预防性) |
| user_id | TEXT | Supabase auth user 域 | |
| source_platform | TEXT | DEFAULT 'rb' | 'rb' / 'rvh' |
| mastery_level | TEXT | DEFAULT 'level0' | level0-level5 |
| easy_factor | REAL | DEFAULT 2.5 | SM-2 难度因子 |
| interval | INTEGER | DEFAULT 0 | 复习间隔(天) |
| repetitions | INTEGER | DEFAULT 0 | 复习次数 |
| correct_count | INTEGER | DEFAULT 0 | 正确次数 |
| incorrect_count | INTEGER | DEFAULT 0 | 错误次数 |
| added_at | TEXT | NOT NULL | 加入时间 |
| first_reviewed_at | TEXT | 首次复习时间 | |
| last_review_date | TEXT | 最后复习日期 | |
| next_review_date | TEXT | 下次复习日期 | |
| last_review_quality | INTEGER | 上次复习质量(1-5,用于 2-button 序列推导) | |
| average_response_time | REAL | 平均响应耗时 | |
| created_at | TEXT | NOT NULL | |
| updated_at | TEXT | NOT NULL | |
| synced_at | TEXT | 最后同步时间 | |
| deleted_at | TEXT | Soft-delete 标记 |
约束:UNIQUE(user_id, word COLLATE NOCASE)(避免双端独立 INSERT 同 word,与 Supabase v43 一致)
4. 阅读笔记表
reading_notes — 笔记容器
一个笔记 = 一组阅读来源(如一篇网页文章、一本 EPUB)。
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | TEXT | PK | UUID |
| title | TEXT | NOT NULL | 笔记标题 |
| author | TEXT | 作者 | |
| note_type | TEXT | DEFAULT 'webArticle' | webArticle / epub |
| cover_image_data | BLOB | 封面图字节(压缩后 JPEG,192×192 q85,硬上限 100KB) | |
| cover_image_mime | TEXT | 'image/jpeg' | |
| cover_source_url | TEXT | 原始来源 URL(debug / 重抓) | |
| description | TEXT | 描述 | |
| reading_progress | REAL | DEFAULT 0.0 | 阅读进度 0-1 |
| source_platform | TEXT | DEFAULT 'rb' | rb / rvh |
| created_at | TEXT | NOT NULL | |
| updated_at | TEXT | NOT NULL | |
| synced_at | TEXT |
reading_pages — 阅读来源
笔记下的具体内容来源。
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | TEXT | PK | UUID |
| note_id | TEXT | FK → reading_notes(id) NOT NULL | |
| name | TEXT | NOT NULL | 来源名称 |
| source_type | TEXT | DEFAULT 'web' | web / book(EPUB) / text(粘贴文本)。无 CHECK 约束 —— 新增取值不需要迁移,text 即据此免费落地 |
| image_path | TEXT | 图片路径(OCR 场景) | |
| source_ref | TEXT | URL 或文件路径 | |
| ocr_text | TEXT | OCR 识别文本 | |
| word_count | INTEGER | 字数 | |
| created_at | TEXT | NOT NULL | |
| updated_at | TEXT | ||
| synced_at | TEXT | ||
| content_html | TEXT | 早期保留字段 | |
| cached_file_path | TEXT | 本地 library 相对路径(e.g. web/{hash}.html) | |
| user_id | TEXT | ||
| storage_path | TEXT | Supabase Storage reading-snapshots 桶内路径({user_id}/{rel}) | |
| last_opened_at | TEXT | Sprint C-1 跨端续读(v12 加)。log_resource_open 在导航事件 touch(仅已存在行);sync 双向,pull merge 取 MAX;HomePage Continue Reading section 数据源;RVH 端不写此列 | |
| deleted_at | TEXT | Soft-delete 标记(v23 加,笔记四级删除闭环)。remove_reading_page 事务软删来源 + 级联软删其 word_page_links/page_annotations;ensure_source_for_url 重新 engage 时复活(清 deleted_at);log_resource_open 加 deleted_at IS NULL(仅导航不复活);sync push/pull 墓碑三分支传播 |
索引:idx_pages_last_opened (user_id, last_opened_at DESC) WHERE last_opened_at IS NOT NULL。
domain_prefs — 域名偏好(同步表)
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| user_id | TEXT | NOT NULL DEFAULT '' | 用户 ID('' 为未认证 sentinel,登录时清理) |
| domain | TEXT | NOT NULL | 域名 |
| read_mode | TEXT | DEFAULT 'original' | 阅读净度档位:original / calm / reader(calm 2026-07-12 WP2 新增;应用层枚举、无 CHECK 约束,便于 additive 演进。前向兼容三纪律——读时白名单降级 / 存储原样透传 / 写回克制,见 docs/plans/archive/reading-cleanliness-tiers-plan.md §4) |
| note_id | TEXT | FK → reading_notes(id) ON DELETE SET NULL | 关联笔记 |
| updated_at | TEXT | ||
| synced_at | TEXT | ||
| deleted_at | TEXT | Soft-delete 标记 | |
| PRIMARY KEY (user_id, domain) | 复合主键 |
domain_zoom — 域名整页缩放(本地表,不同步)
按域名持久化整页几何缩放倍率(webview pageZoom)。独立于 domain_prefs——避免污染其「行存在 = 用户显式表过 read_mode 态」的 nudge 语义(migrations.rs v26)。local-only:不进 Supabase 同步矩阵(RVH 无浏览/缩放概念),但含用户浏览域名 → 纳入 auth.rs 换用户/登出清理(红线 5c)。
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| user_id | TEXT | NOT NULL DEFAULT '' | 用户 ID('' 为未认证 sentinel,登录时清理) |
| domain | TEXT | NOT NULL | 域名 |
| zoom | REAL | NOT NULL DEFAULT 1.0 | 整页缩放倍率(1.0 = 100%) |
| updated_at | TEXT | ||
| PRIMARY KEY (user_id, domain) | 复合主键 |
5. 词汇关联表
word_page_links — 词-来源关联
记录一个词在哪个来源中被学习。
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | TEXT | PK | UUID |
| learning_entry_id | TEXT | FK → learning_entries(id) NOT NULL | |
| reading_page_id | TEXT | FK → reading_pages(id) NOT NULL | |
| lemma | TEXT | DEFAULT '' | 该语境中词的归一形/词目(非形态学词根,详见 vocabulary-domain-knowledge.md §2) |
| created_at | TEXT | NOT NULL | |
| updated_at | TEXT | ||
| synced_at | TEXT | ||
| deleted_at | TEXT | Soft-delete 标记(v23 加,笔记四级删除闭环)。remove_word_page_link 软删单条;remove_reading_page 级联软删;save_word/batch_save_words/add_word_page_link 的写入用 ON CONFLICT DO UPDATE SET deleted_at=NULL 复活(红线 #7);所有消费查询加 ws.deleted_at IS NULL;sync 墓碑三分支传播 | |
| UNIQUE(learning_entry_id, reading_page_id) |
6. 基础设施表
settings — 键值配置
| 列 | 类型 | 说明 |
|---|---|---|
| key | TEXT PK | 配置键 |
| value | TEXT NOT NULL | 配置值 |
存储:Supabase URL/anon_key、auth_user_id、last_sync_at / last_sync_attempt_at、vocabulary_seed_version 等。
recommended_sites — 推荐站点(预装导航)
预装英文站点(BBC/NPR/Aeon 等),App 启动时从 Supabase recommended_sites 远程刷新覆盖 is_default=1 的记录。首页 Recommended Sites 区数据源。
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PK | |
| name | TEXT | NOT NULL | 站点名 |
| url | TEXT | NOT NULL | 站点 URL |
| category | TEXT | NOT NULL | 分类 |
| icon_url | TEXT | 图标 URL | |
| sort_order | INTEGER | DEFAULT 0 | 排序 |
| is_default | INTEGER | DEFAULT 1 | 是否预装默认(远程刷新只覆盖 =1 的行) |
| has_rss | INTEGER | DEFAULT 0 | 是否有 RSS 源 |
| rss_url | TEXT | RSS 源 URL | |
| cefr_band | TEXT | v19 加列,站点难度档(A2/B1/B2/C1,discover-sites grounded 审核算出 + ?mode=backfill-bands 回填,NULL=未知);首页 tier-aware 站点分桶排序用 |
rss_feeds / rss_items — RSS 订阅(feeds 同步,items 不同步)
- rss_feeds:
id TEXT PRIMARY KEY(UUID) + user_id + 同步列;UNIQUE(user_id, url)。 - rss_items:
feed_id TEXT引用 rss_feeds.id;不参与同步(每端独立拉取,feed 删除/切换用户时按feed_id NOT IN (SELECT id FROM rss_feeds)孤儿清理)。
| rss_feeds 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | TEXT | PK | UUID |
| user_id | TEXT | 用户 ID(NULL = 未认证 sentinel) | |
| title | TEXT | ||
| url | TEXT | NOT NULL | UNIQUE(user_id, url) |
| site_url | TEXT | ||
| tag | TEXT | v8 加列,从 recommended_feeds.tag 透传;手动订阅时为 NULL | |
| last_fetched | TEXT | ||
| created_at | TEXT | NOT NULL | |
| updated_at | TEXT | ||
| synced_at | TEXT | ||
| deleted_at | TEXT |
favorite_sites — 网站收藏(domain 级别,同步表)
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | TEXT | PK | UUID |
| user_id | TEXT | 用户 ID(NULL = 未认证 sentinel) | |
| domain | TEXT | NOT NULL | 域名(如 bbc.com);UNIQUE(user_id, domain COLLATE NOCASE) |
| name | TEXT | 站点名称 | |
| url | TEXT | NOT NULL | 站点首页 URL |
| icon_url | TEXT | 站点图标 URL | |
| site_type | TEXT | DEFAULT 'user' | user / recommended |
| category | TEXT | v8 加列,从 recommended_sites/RecommendedSite.category 透传;手动收藏时为 NULL | |
| created_at | TEXT | NOT NULL | |
| updated_at | TEXT | ||
| synced_at | TEXT | ||
| deleted_at | TEXT | Soft-delete 标记 |
resource_history — 资源访问历史(本地,不同步)
统一记录 web/epub 打开记录(去重),首页 Recently Opened + 历史面板数据源。per-user 隔离(v4)。
🔒 粘贴文本刻意记作
'web'(不是'text')。resource_type这一列有 CHECK 约束, 而 SQLite 不支持ALTER TABLE DROP CONSTRAINT,放开它要整表重建 —— 为历史面板的类型筛选 做一次表重建不划算(本仓最需谨慎的操作类型)。代价仅是历史面板里粘贴文本与网页混在一起。 ⚠️ 后续会话若"顺手"把它改成'text',会当场 CHECK 违约。见paste-text-source-plan.md§5.6。
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PK | |
| user_id | TEXT | per-user 隔离 | |
| resource_type | TEXT | CHECK IN ('web','epub') | 粘贴文本记 'web'(见上方说明) |
| uri | TEXT | URL 或文件路径;UNIQUE(user_id, uri) | |
| title | TEXT | ||
| last_opened_at | TEXT | NOT NULL | |
| open_count | INTEGER | DEFAULT 1 |
reading_log — 阅读会话统计(本地,不同步)
每次阅读会话写一行明细(save_reading_log 周期性写入)。StatsPanel 的 trends/weekly/monthly/streaks 数据源(v13 补建)。与 resource_history 互补:后者"访问过哪些"(去重),前者"每次读了多少/多久/几个生词"(可 SUM/GROUP BY)。
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PK AUTOINCREMENT | |
| url | TEXT | NOT NULL | |
| title | TEXT | ||
| domain | TEXT | ||
| word_count | INTEGER | NOT NULL DEFAULT 0 | 本次阅读字数 |
| new_words | INTEGER | NOT NULL DEFAULT 0 | 出现生词数 |
| cefr_level | TEXT | ||
| read_time | INTEGER | NOT NULL DEFAULT 0 | 阅读时长(秒) |
| created_at | TEXT | NOT NULL | |
| user_id | TEXT | v27 加:阅读统计 per-user。所有 stats 查询按 current_user_id 过滤,消除换用户后旧用户阅读趋势泄漏。不进换用户清理循环(按 user_id 留存、查询隔离 = 零数据丢失)。仍不同步(设备本地)。存量行迁移时回填当前 auth_user_id |
recommended_feeds — 推荐 RSS 源(预装)
App 启动时从 Supabase recommended_feeds 刷新。首页 Recommended Feeds 区数据源。
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PK | |
| title | TEXT | NOT NULL | |
| url | TEXT | NOT NULL UNIQUE | |
| tag | TEXT | NOT NULL | 分类标签 |
| description | TEXT | ||
| sort_order | INTEGER | DEFAULT 0 | |
| is_default | INTEGER | DEFAULT 1 | |
| quality_score | INTEGER | DEFAULT 3 | |
| is_active | INTEGER | DEFAULT 1 | |
| fetched_at | TEXT | ||
| cefr_band | TEXT | v19 加列,继承父站 band(同 rss_url,NULL=未知);首页 tier-aware feed 分桶排序用 |
default_stopwords — 系统默认停用词(本地预装,不拉取)
系统停用词池(a/the/is…)。2026-07 决策5 起纯本地预装:由 init_data.sql 安装期播种 211 行(id 沿用确定性 sys-ew-*,RB/RVH 跨端一致),不再从 Supabase 拉取(Supabase 公开表已 DROP,fetch_default_stopwords 拉取路径已删)。仅 3 列;职责单一——作为 merge 源把词 INSERT OR IGNORE 进用户的 known_words(apply_default_stopwords 在 init/登录时触发)。
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | TEXT | PK | |
| word | TEXT | NOT NULL COLLATE NOCASE | UNIQUE(word COLLATE NOCASE) |
| sort_order | INTEGER | DEFAULT 0 |
pronunciation_attempts — 跟读评分历史(本地,不同步)
Azure Speech 发音评分明细,per-user。v11 新建,local-only MVP,同步矩阵留后续。
word_cloze_contexts — 复习卡 cloze 真实语境(同步,v24 起)
roadmap ⑥。存用户读到某词时的真实句 + 原始点击形 surface,复习卡正面据此把生词挖空(回忆卡)。 列:id / word(归一 lemma,join 复习卡)/ surface(精确挖空目标)/ sentence / source_url / user_id / created_at / sense_gloss(v17,roadmap ⑥+:消歧出的贴合该句语境的义项 gloss 文本,复习背面按文本重定位高亮;存 gloss 非 index 规避 reseed 漂移;NULL=未消歧/非多义;由 record_cloze_sense 按 (word, sentence) 句级回写) / updated_at / deleted_at / synced_at(v24,同步三列)。 v21 多语境池(docs/plans/archive/multi-context-cloze-plan.md):去掉原 UNIQUE(user_id, word),一词多行; 去重(同句 COLLATE NOCASE no-op,或复活同句软删行)+ 封顶 5 条(满池软删最旧)由应用层 insert_cloze_context 保证。 收集路径 = 查词即自动收录:save_word(首存)与 add_word_page_link(已存词再查,弹窗「已收录本句语境 · 撤销」→ remove_cloze_context)。 复习卡按 repetitions % 语境数 轮换测试句,揭晓后可在语境间切换(‹n/m› + 顶栏 stepper)。 v16 新建 / v21 去 UNIQUE 重建 / v24 纳入跨端同步矩阵(ALTER ADD updated_at/deleted_at/synced_at, 撤销/满池替换/来源级删除全部从硬删除改软删除);含用户阅读句 → clear_learning_data_if_user_changed
clear_user_learning_data(signout)均已纳入(红线 5c)。 merge 是本表独有难点:两端各自封顶 5 条的池子 pull 合并后可能变 10 条,pull_cloze_contexts三分支 墓碑传播后跑reconcile_cloze_pool(按句去重 + 重新封顶 5),详见 §8 及docs/cross-end/16-rb-cloze-context-sync-handoff.md。 无真实句时复习卡前端回落策展例句(vocabulary.pos_definitions)挖空,故此表稀疏、仅承载"用户自己读过的句"。
word_personalized_examples — 个性化例句缓存(本地,不同步)
roadmap ④。复习卡揭晓侧"再看一个新语境例句"按需生成的结果缓存。锚定 sense_gloss(贴合语境义项)+ 用户真实读句 + 兴趣信号(get_reading_interest_signals 的常读 domain / 订阅 tag),经 Edge Function generate-example 生成贴近用户兴趣的新英文例句 + 中文翻译。 列:id / user_id / word(归一 lemma,join 复习卡)/ example_text / example_zh / sense_gloss(生成时锚定义项,仅来源标注)/ created_at。 无 UNIQUE:一词多条("换一个"累积),generate_personalized_example 命令插入后按 (user_id, word) prune 仅留最新 5 条。v18 新建,local-only(migration-only 不动 baseline)。不进同步:个性化锚定单用户、与缓冲池 跨用户摊薄互斥(取个性化);跨端可见性挂 ⑥ cloze v2(RVH 新会话)。含用户兴趣派生内容 → clear_learning_data_if_user_changed 已纳入(红线 5c)。opt-in 复用 context_disambiguation,无独立开关。
epub_positions — EPUB 章内阅读位置(本地,不同步)
backlog B4 剩余部分的落点(migration v32)。「恢复到章」2026-08-07 已用 reading_pages.source_ref 里现成的 fragment 零 migration 做完;章内偏移没有任何现成载体。 实测支撑:最长章可滚 65,977px ≈ 47 屏,读者确实会在一章里读很远,重开却只回章首。
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PK AUTOINCREMENT | |
| user_id | TEXT | NOT NULL | 未登录时命令 no-op(不留 NULL 行) |
| file_path | TEXT | NOT NULL | 剥掉 fragment 的纯 .epub 路径 |
| source_ref | TEXT | NOT NULL | 章:路径#锚点 / 路径#ch{N},与 reading_pages.source_ref 同形 |
| block_idx | INTEGER | NOT NULL DEFAULT 0 | 章内第 N 个文本块 |
| block_frac | REAL | NOT NULL DEFAULT 0 | 该块内已滚过的比例 0..1 |
| updated_at | TEXT | NOT NULL DEFAULT (now) | |
| UNIQUE(user_id, file_path) | 一本书一行(UPSERT 原地更新) |
存结构量不存像素:settings.js 对 rb-cache:// 页的整页重排版实测把文档撑 +85% (B5 的真凶),像素当场失真;块序号是 DOM 结构量,换字号 / 栏宽 / 窗宽都不变。 也不存「最近可见的锚点 id」——2026-08-13 实测两本书的锚点密度正好相反: Gutenberg 每 618 字符一个锚点(很好),Standard Ebooks 每章只有一个(53,936 字符一个, 等于只能恢复到章);而 <p> 密度反过来(1,368 : 327),块序号在两类书上都够密。
为什么是新表而不是 reading_pages 加两列:那张表的行只在存词 / 划线时经 ensure_source_for_url 产生,而本表的场景恰恰是只读不存词。新表也保证不 touch last_opened_at → 「继续阅读」排序行为零变化。
block_idx 的含义只由 content-script 定义(features/epub-anchor.js::collectPositionBlocks, 存取共用同一枚举口径)——Rust 侧只当仓库,别在第二处解释它。
per-user 走 user_id + 读查询过滤(沿 v27 reading_log / v31 先例:不进 auth.rs 换用户 清理循环,换用户靠过滤天然隔离)。不进同步:RVH 无 EPUB 阅读概念,且 file_path 是设备本地路径。 消费方 commands/epub/position.rs;落点决策在前端 lib/epubStart.ts::resolveEpubStart。
word_encounters — 候选词跨页复现计数(本地,不同步)
roadmap 3-2「待发现词前台收敛」的排序信号表(migration v31)。DiscoveryPanel 潜在词从字母序 改为价值排序,四维信号里「跨文复现」在本仓没有任何数据源——word_page_links 是 JOIN learning_entries(只记已存词的来源页)、reading_log 只存聚合计数、无 per-page 词索引。
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PK AUTOINCREMENT | |
| word | TEXT | NOT NULL COLLATE NOCASE | 候选词(值来自 vocabulary.word,写前过 lemmatizer::normalize) |
| user_id | TEXT | 当前用户;未登录时命令 no-op 不写 NULL 行 | |
| page_count | INTEGER | NOT NULL DEFAULT 1 | 读到过的次数(不是「篇数」,见下) |
| last_page_url | TEXT | 去重闸,非历史记录 | |
| last_seen_at | TEXT | NOT NULL DEFAULT (now) | |
| UNIQUE(word, user_id) | per-user 一词一行 |
设计要点 = 聚合表而非 (word,url) 明细表:行数被候选池大小封顶(≤ ~12k = 预装库落在启用 CEFR 档 的词数),不随阅读量线性增长;明细表在重度用户身上是 ~1M 行/年。
last_page_url 是去重闸:highlightDiscoverable 会因 SPA 导航 / MutationObserver 在同一页多次 重跑,没有这个闸单页就被反复 +1(UPSERT 用 CASE WHEN last_page_url IS ?3 的 NULL-safe 相等)。 接受的近似:A→B→A 回访会重复计数 → UI 文案只说「读到过 N 次」,绝不说「N 篇文章」。
per-user 走 user_id + 读查询过滤(沿 v27 reading_log 先例:零数据丢失、不进 auth.rs 换用户清理循环,换用户靠过滤天然隔离)。不进同步:RVH 无浏览/页面发现概念。 消费方 commands/vocabulary/discovery.rs;打分排序在前端 lib/discoveryRank.ts。
phrase_interaction_log(已退役,v25 DROP):v20 建的短语自动高亮 thin-slice 验证埋点。 全仓从无 SELECT(纯只写)、设计的
dismissed事件从无触发点、gate 验证早已改走[RB-EVAL]console + 逐句判定绕开本表;静态治理冻结后再无消费者 → v25DROP TABLE。前向迁移退役(不改 已应用的 v20,规避 sqlx checksum 校验失败)。
7. 索引策略
高频查询索引
| 表 | 索引 | 用途 |
|---|---|---|
| vocabulary | word COLLATE NOCASE | 查词(大小写不敏感) |
| vocabulary | primary_cefr_level | CEFR 过滤 |
| vocabulary | frequency_rank | 词频排序 |
| learning_entries | next_review_date | 获取到期复习卡片 |
| learning_entries | word | 按词汇文本查学习状态(v43 后 PK 直查) |
| reading_log | created_at / (url, created_at) | StatsPanel 趋势/streak 聚合 |
| resource_history | (user_id, last_opened_at) | 首页 Recently Opened |
同步索引
所有同步表在 (user_id, updated_at) 上有组合索引(Supabase 端),用于增量拉取。
注意事项
- 禁止
LOWER():SQLite 不支持 Unicode,所有大小写不敏感查询用COLLATE NOCASE - 索引命名约定:
idx_{表缩写}_{列名}
8. Soft-Delete 与同步
Soft-Delete(10 张表有 deleted_at)
learning_entries, known_words, favorite_sites, rss_feeds, domain_prefs, page_annotations, reading_pages, word_page_links(均同步;后两张 v23 新增,笔记四级删除闭环), word_cloze_contexts(v24 新增)+ reference_words(本地系统表,带 deleted_at 但不同步)。
删除 → UPDATE SET deleted_at = datetime('now')(不做 DELETE)
查询 → WHERE deleted_at IS NULL
保存 → 若已 soft-delete,重新激活(learning_entries 重置 SM-2 默认值)同步 Push 条件
sql
-- 正常变更
WHERE updated_at > synced_at
-- Soft-delete 传播(fallback)
OR deleted_at > synced_at同步 Pull Merge
对于 learning_entries,取 remote 若满足任一:
- remote.repetitions > local.repetitions
- remote.next_review_date > local.next_review_date
- remote.mastery_level > local.mastery_level
对于 reading_notes/reading_pages、word_page_links:
- 直接 upsert(remote 优先)
- word_page_links 插入前检查 FK 存在性(notebook_entry / reading_source)
- reading_pages / word_page_links soft-delete 墓碑三分支(v23,模板=learning_entries):
remote 删+本地有 → 标墓碑;本地已删+remote 活 → skip(不复活);本地无+remote 删 → skip 不落墓碑(FK-safe)
对于 word_cloze_contexts(v24):
- 三分支墓碑传播同上(不做 FK 存在性检查,沿 v16 松耦合设计);sense_gloss 取非空一方回填
- **额外一步(本表独有)**:pull 结束后对本批次触达的每个 word 跑 reconcile_cloze_pool——
按句 COLLATE NOCASE 去重 + 软删最旧超额行,把两端各封顶 5 条合并后的池子重新收敛到 ≤5 条9. 本地表 ↔ Supabase 表映射
映射总览
| 本地表 | Supabase 表 | 差异 |
|---|---|---|
| learning_entries | user_learning_entries | 三端已对齐(v43 PK 由 vocabulary_id 改 word + UNIQUE(user_id, word)) |
| reading_notes | user_reading_notes | 三端已对齐(v34 加 user_id/full_text_path) |
| reading_pages | user_reading_pages | 三端已对齐(v34 加 user_id) |
| word_page_links | user_word_page_links | 三端已对齐(v34 加 user_id) |
| word_cloze_contexts | user_word_cloze_contexts | 2026-07-09(v24)新增,全新建表;on_conflict 业务键 (user_id, word, sentence),RVH 待镜像 |
| known_words | user_known_words | 三端已对齐(v34 加 user_id) |
| favorite_sites | user_favorite_sites | 2026-04-21 阶段 C 新增(id TEXT UUID + user_id + 同步列) |
| rss_feeds | user_rss_feeds | 2026-04-21 阶段 C 新增(id TEXT UUID + user_id + 同步列) |
| domain_prefs | user_domain_prefs | 2026-04-21 阶段 C 新增(复合 PK (user_id, domain) + 同步列) |
| page_annotations | user_page_annotations | 2026-04-22 阶段 D 新增(PK id TEXT + user_id + 同步列;reading_page_id 逻辑 FK) |
所有 Supabase 用户表启用 RLS,策略:auth.uid() = user_id。
不同步的本地表(12 张):vocabulary(预装库 + Supabase 缓冲池回填)、settings、recommended_sites、recommended_feeds、recommended_articles(这三张为 ← Supabase 单向拉取/缓存)、default_stopwords(纯本地预装 seed,2026-07 决策5 起不拉取)、rss_items、resource_history、reading_log、pronunciation_attempts、word_personalized_examples(个性化例句缓存,v18;个性化锚定单用户、与缓冲池摊薄互斥)、reference_words(系统共享,~199 条预装专有名词)。word_cloze_contexts 自 2026-07-09(v24)起移出此列表,纳入同步矩阵(见上方映射表)。phrase_interaction_log 曾在此列表(v20),已于 v25 DROP 退役。
rss_items 不同步说明:RSS 文章内容可由 feed URL 重新拉取,每端独立维护更轻量;同时
feed_idFK 跟随 rss_feeds 同步生命周期,user 切换时通过feed_id NOT IN (SELECT id FROM rss_feeds)孤儿清理。
推荐系统表映射(公开表,无需登录)
| Supabase 表 | 本地缓存 | 说明 |
|---|---|---|
| recommended_sites | recommended_sites | 站点推荐池,含 quality_score / is_active / has_rss |
| recommended_feeds | recommended_feeds | RSS 源推荐池 |
| recommended_articles | recommended_articles | AI 推荐文章,7 天过期 |
| 已下线拉取(2026-07 决策5):改纯本地预装 seed,Supabase 公开表已 DROP |
推荐表启用 RLS,策略:SELECT USING (true)(公开只读)。
App 启动时后台 fetch 三张推荐表 → 缓存到本地 → 首页展示。网络失败时降级到本地数据。
recommended_articles(本地表)
| 列 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PK | 本地自增 |
| remote_id | INTEGER | Supabase 端 id |
| title | TEXT NOT NULL | 文章标题 |
| url | TEXT NOT NULL | 文章 URL |
| source_name | TEXT | 来源名 |
| summary | TEXT | 摘要 |
| cefr_level | TEXT DEFAULT 'B1' | CEFR 等级 |
| category | TEXT | 分类 |
| recommendation_reason | TEXT | 中文推荐理由 |
| published_at | TEXT | 发布时间 |
| fetched_at | TEXT NOT NULL | 拉取时间 |
推荐算法详见:
docs/recommendation-algorithm.md
执行脚本:
supabase/sql/sync-tables.sql(在 Supabase Dashboard → SQL Editor 中运行)
Supabase 表完整列定义
user_learning_entries
| 列 | 类型 | 约束 | 本地对应 |
|---|---|---|---|
| id | TEXT | PK | id |
| user_id | UUID | NOT NULL FK → auth.users(id) CASCADE | user_id |
| word | TEXT | NOT NULL | word |
| mastery_level | TEXT | NOT NULL DEFAULT 'level0' | mastery_level |
| easy_factor | REAL | NOT NULL DEFAULT 2.5 | easy_factor |
| interval | INTEGER | NOT NULL DEFAULT 0 | interval |
| repetitions | INTEGER | NOT NULL DEFAULT 0 | repetitions |
| correct_count | INTEGER | DEFAULT 0 | correct_count |
| incorrect_count | INTEGER | DEFAULT 0 | incorrect_count |
| added_at | TEXT | NOT NULL | added_at |
| first_reviewed_at | TEXT | first_reviewed_at | |
| last_review_date | TEXT | last_review_date | |
| next_review_date | TEXT | next_review_date | |
| last_review_quality | INTEGER | last_review_quality | |
| average_response_time | REAL | average_response_time | |
| source_platform | TEXT | NOT NULL DEFAULT 'rb' | source_platform |
| deleted_at | TEXT | deleted_at | |
| created_at | TEXT | NOT NULL | created_at |
| updated_at | TEXT | NOT NULL | updated_at |
约束:UNIQUE(user_id, word)(v43 加,与本地约束对齐)
索引:(user_id), (word), (user_id, next_review_date), (user_id, updated_at)
user_reading_notes
| 列 | 类型 | 约束 | 本地对应 |
|---|---|---|---|
| id | TEXT | PK | id |
| user_id | UUID | NOT NULL FK → auth.users(id) CASCADE | ❌ 本地无 |
| title | TEXT | NOT NULL | title |
| author | TEXT | author | |
| note_type | TEXT | DEFAULT 'webArticle' | note_type |
| cover_image_data | BYTEA | cover_image_data(PostgREST \x hex transit) | |
| cover_image_mime | TEXT | cover_image_mime | |
| cover_source_url | TEXT | cover_source_url | |
| description | TEXT | description | |
| reading_progress | REAL | DEFAULT 0.0 | reading_progress |
| source_platform | TEXT | NOT NULL DEFAULT 'rb' | source_platform |
| created_at | TEXT | NOT NULL | created_at |
| updated_at | TEXT | NOT NULL | updated_at |
索引:(user_id), (user_id, updated_at)
user_reading_pages
| 列 | 类型 | 约束 | 本地对应 |
|---|---|---|---|
| id | TEXT | PK | id |
| user_id | UUID | NOT NULL FK → auth.users(id) CASCADE | ❌ 本地无 |
| note_id | TEXT | NOT NULL | note_id |
| name | TEXT | NOT NULL | name |
| source_type | TEXT | NOT NULL DEFAULT 'web' | source_type |
| image_path | TEXT | image_path | |
| source_ref | TEXT | source_ref | |
| ocr_text | TEXT | ocr_text | |
| word_count | INTEGER | word_count | |
| storage_path | TEXT | storage_path(bucket reading-snapshots 内路径 {user_id}/{rel}) | |
| last_opened_at | TEXT | last_opened_at(Sprint C-1 跨端续读;RVH 端 pull-only) | |
| created_at | TEXT | NOT NULL | created_at |
| updated_at | TEXT | updated_at | |
| deleted_at | TEXT | deleted_at(v23 笔记四级删除,Dashboard ALTER … ADD COLUMN IF NOT EXISTS deleted_at;RVH 待镜像) |
索引:(user_id), (user_id, note_id), (user_id, updated_at), (user_id, last_opened_at DESC) WHERE last_opened_at IS NOT NULL
Storage:配套 Supabase Storage 私有 bucket
reading-snapshots,RLS 按split_part(name,'/',1) = auth.uid()隔离(见supabase/sql/storage-setup.sql)。
user_word_page_links
| 列 | 类型 | 约束 | 本地对应 |
|---|---|---|---|
| id | TEXT | PK | id |
| user_id | UUID | NOT NULL FK → auth.users(id) CASCADE | ❌ 本地无 |
| learning_entry_id | TEXT | NOT NULL | learning_entry_id |
| reading_page_id | TEXT | NOT NULL | reading_page_id |
| lemma | TEXT | NOT NULL DEFAULT '' | lemma |
| created_at | TEXT | NOT NULL | created_at |
| updated_at | TEXT | updated_at | |
| deleted_at | TEXT | deleted_at(v23 笔记四级删除,Dashboard ALTER … ADD COLUMN IF NOT EXISTS deleted_at;RVH 待镜像) | |
| UNIQUE(learning_entry_id, reading_page_id) |
索引:(user_id), (user_id, updated_at)
Supabase 运营 / 配额表(不进同步矩阵、不进 RB 本地镜像)
这几张表只存在于 Supabase,由 Edge Function 以
service_role写入。 DDL 权威源:supabase/sql/observability-tables.sql+supabase/sql/quota-and-attribution.sql(Dashboard 手动执行)。设计缘由见docs/plans/archive/llm-server-unification-plan.md。
| 表 | 作用 | 关键列 | RLS |
|---|---|---|---|
llm_call_log | 每次 LLM 调用一行(成本核算) | user_id(2026-08 加) / function_name / prompt_tokens / completion_tokens / cost_usd / status | 仅 service_role |
tts_call_log | TTS 账本。kind='synthesize' 精确计字符;kind='token_issue' 是发放日志不是用量日志(token 出门后客户端直连 Azure,服务端不可见) | user_id / kind / char_count / cost_usd(仅 status='ok' 时有值) | 仅 service_role |
user_quota_usage | 配额计数器。数的是用户动作,与 llm_call_log 的调用次数故意不同粒度 | PK (user_id, period, meter) / units / cost_usd(尽力而为,权威成本看两张 log 表) | 用户可 SELECT 自己那行;写仅 service_role |
quota_config | 单行阈值表(id=1)。调阈值 = 一条 SQL UPDATE,不改代码不重部署 | llm_calls_limit / llm_calls_passive_limit / translate_paragraphs_limit / tts_chars_limit / speech_tokens_limit | 仅 service_role |
edge_run_log / recommend_audit_log | 推荐链路 run 审计(Phase 1 既有) | — | 仅 service_role |
meter 取值:llm_calls(主动:拆解/消歧/例句/指代/周报)· llm_calls_passive(被动:短语自动高亮)· translate_paragraphs(按段)· tts_chars(按字符)· speech_tokens(按发放次数)。
主动/被动为何分开:实测
judge-phrases-batch是被动触发的(用户只是在读页面, 短语高亮就在后台跑)且吃掉 78% 的 token。与主动动作共用一个 meter 会让用户 因为读得多而撞墙,违反「墙不能压到核心阅读闭环」。匿名调用记入哨兵
user_id = '00000000-0000-0000-0000-000000000000'。 该行永远不等于任何auth.uid(),RLS 天然不暴露;它同时是「RVH 是否已完成 JWT 迁移」 的观测指标(停止增长 = 可进 WP3 阶段 2 拒绝匿名)。
本地 vs Supabase 差异总结
| 差异点 | 说明 |
|---|---|
user_id | Supabase 每张表多 user_id UUID 列,用于 RLS 隔离 |
source_platform | learning_entries + reading_notes 有此列,标识数据来源(rb/rvh) |
synced_at | 本地有、Supabase 无 — 仅用于本地判断"哪些行需要 push" |
deleted_at | learning_entries 两端都有,用于 soft-delete 跨端传播 |
last_review_quality | learning_entries 两端都有(通过 ALTER TABLE 增量添加) |
10. 迁移版本历史
🔒 2026-07-26 数据基线冻结(基线
0.1.0-dev.8):旧 v1→v27 全链已折叠进 v1 baseline(schema.sql),自此 append-only——新表/列只进 v28+(详见 CLAUDE.md §5 + 红线 #11)。下表 v4-v27 均已折叠进 v1,仅作历史索引。
当前链 = v1(冻结 baseline)+ v2(seed init_data)+ v3(seed reference_words)+ v28…v34,下一个新迁移从 v35 开始 (v28 起是冻结后的 append-only 新增,均不动 schema.sql)。
📍 本节是迁移史的唯一真相源。 CLAUDE.md §5 只留冻结纪律与链头指针,不再并行维护第二张表 ——两边各写一遍必然漂移(2026-08 实测:两份表同时漏记 v33,且 CLAUDE.md 那份把早已完成的 v28 收尾项写作"阻塞中")。核对链头请以
src-tauri/src/db/migrations.rs为准,它比任何文档新鲜。
| 版本 | 内容 |
|---|---|
| v1 | Schema snapshot(assets/sql/schema.sql,2026-07-26 冻结)— 全部表 + 索引 baseline(含旧 v4-v27 全部 schema 效果) |
| v2 | Seed init data(settings、recommended_sites、known_words 停用词) |
| v3 | Seed reference_words(~199 条系统专有名词/缩写) |
| v4 | resource_history 加 user_id + UNIQUE(user_id, uri) 重建(阶段 A) |
| v5 | known_words 拆 per-user:system 停用词剥离到 default_stopwords(阶段 F) |
| v6 | page_annotations 加 user_id + 同步列(阶段 D) |
| v7 | reading_notes: cover_image_path → cover_image_data BLOB + cover_image_mime + cover_source_url |
| v8 | favorite_sites + rss_feeds: ALTER ADD COLUMN(favorite_sites.category / rss_feeds.tag),不动 v1 baseline 避免 hash 校验拒绝 |
| v9 | vocabulary: +cefr_inferred/+cefr_source/+word_tags(RVH v48-v50);learning_entries word FK CASCADE→RESTRICT(RVH v51) |
| v10 | vocabulary 预装源从 vocabulary_seed.sql (4,953) 切到 lampio_dict.db (12,291,与 RVH byte-equal);migration 只清 vocabulary_seed_version,重灌由 helpers.rs ATTACH+INSERT 完成 |
| v11 | pronunciation_attempts 表新建(per-user 跟读评分,local-only MVP) |
| v12 | reading_pages: ALTER ADD last_opened_at + 索引(Sprint C-1 跨端续读) |
| v13 | reading_log: 补建表(stats 数据源,6a37c51 压平时漏建) |
| v14 | vocabulary: ALTER ADD emoji(词汇插图 OpenMoji hexcode,RVH v53 additive) |
| v15 | vocabulary: ALTER ADD canonical_surface(习语可读引用形展示,RVH v54 additive) |
| v16 | word_cloze_contexts 表新建(复习卡 cloze 真实语境,local-only;save_word 加 context_sentence 入参) |
| v17 | word_cloze_contexts ALTER ADD sense_gloss(复习卡背面贴合语境义项高亮,存 gloss 文本;record_cloze_sense 回写) |
| v18 | word_personalized_examples 表新建(roadmap ④ 个性化例句缓存,local-only;generate_personalized_example 命令 + Edge Function generate-example) |
| v19 | recommended_sites + recommended_feeds: ALTER ADD cefr_band(tier-aware 推荐排序,additive) |
| v20 | phrase_interaction_log 表新建(短语自动高亮 thin-slice v1 验证埋点,local-only;log_phrase_interaction 命令 + phrase_auto_highlight 开关) |
| v21 | word_cloze_contexts 去 UNIQUE(user_id, word) 重建(一词多语境池:应用层去重 + 封顶 5;收集=查词即自动收录,复习=轮换+切换。详见 docs/plans/archive/multi-context-cloze-plan.md) |
| v22 | word_cloze_contexts prune 展示护栏外句长存量行(8..220 chars;配套入池闸三处对齐:Rust insert_cloze_context / popup.js checkContextIsNew / cloze.ts MIN·MAX_SENTENCE_LEN) |
| v23 | word_page_links + reading_pages: ALTER ADD deleted_at(笔记四级删除闭环)。两表同补软删列(子表对 reading_pages FK 均 CASCADE,硬删会先于墓碑 push 抹掉子表墓碑)。配套 remove_reading_page(事务软删+级联 cloze+返回 highlight ids)/ remove_word_page_link / remove_cloze_contexts_for_sentence;三处写入 ON CONFLICT DO UPDATE 复活(红线 #7);sync 双表墓碑三分支。Supabase 两表 Dashboard ALTER ADD deleted_at(须先于新版客户端)。详见 docs/plans/archive/notes-crud-phase2-plan.md |
| v24 | word_cloze_contexts: ALTER ADD updated_at/deleted_at/synced_at(纳入跨端同步矩阵,第 10 张同步表)。撤销/满池替换/来源级删除全部从硬删除改软删除;insert_cloze_context 命中同 (word,sentence) 软删行改为复活(红线 #7)。新增 push_cloze_contexts/pull_cloze_contexts(模板=word_page_links,不做 FK 检查)+ pull 后 reconcile_cloze_pool(按句去重+重新封顶 5,merge 两端各 5 条池子的核心难点)。Supabase 新建 user_word_cloze_contexts(全新表)。顺手补齐 clear_user_learning_data 遗漏的三张含用户内容表。详见 docs/cross-end/16-rb-cloze-context-sync-handoff.md |
| v25 | phrase_interaction_log: DROP TABLE — 短语高亮验证埋点退役(纯只写从无 SELECT、dismissed 从无触发点、gate 验证早已绕开本表、静态治理冻结后无消费者)。前向迁移退役(不改已应用的 v20,规避 sqlx checksum 校验失败)。local-only → 无跨端 / Supabase 协调。同步移除 log_phrase_interaction 命令 + content-script 两处埋点调用 + auth.rs 两处清理清单 |
| v26 | domain_zoom 表新建(按域名持久化整页缩放,local-only;get_domain_zoom/set_domain_zoom) |
| v27 | reading_log: ALTER ADD user_id(阅读统计改 per-user)。设备级表泄漏根治——换账号后新账号继承旧用户阅读趋势(roadmap 2-4 实测暴露 + 隐私)。所有 reading_log 读查询(reading.rs 8 / report.rs 3 / supabase.rs 1)按 current_user_id 过滤;不进 auth.rs 换用户清理循环(按 user_id 留存、查询隔离 = 零数据丢失);存量行回填当前 auth_user_id。ALTER-ADD、不动 baseline schema.sql(红线 #11)。仍 local-only。配套 auth.rs 换用户清理补入 weekly_report_cache(全局 KV 隐私泄漏,沿 last_known_cefr_level 先例) |
| —— 以下为 2026-07-26 冻结后的 append-only 新增(均不动 schema.sql)—— | |
| v28 | usage_events_local 表新建(opt-in 匿名遥测本地缓冲,local-only)。roadmap 4-1。本地表既是网络不稳时的上报缓冲,也是 dev 期读出口(根治 v20/v25「只写不读=死表」教训)。opt-in 默认关(settings.telemetry_opt_in 只认显式 'true');install 级匿名(随机 UUID)、props 只存数值/枚举、绝不含 word/句子/URL/user_id → install 级非 user 级,不进 auth.rs 换用户清理。命令 telemetry_record(no-op-when-off)+ telemetry_flush(fire-and-forget,失败静默留待下次)。冻结后首个 append-only 新表 → golden 测试 flattened_baseline_matches_terminal 收窄到 FROZEN_BASELINE_MAX_VERSION=3。Supabase 建表/RLS + admin 看板消费均已于 2026-07-29 完成 |
| v29 | recommended_articles: ALTER ADD word_count + body_text(roadmap 3-1 Phase 2 正文管线,local-only 缓存列)。analyze-articles 抓正文后下发,客户端算全文级在学词复现 + 词汇覆盖难度 |
| v30 | reading_notes: ALTER ADD deleted_at(四级删除闭环最后一级,cross-end/19)。此前是 10 张同步表里唯一没有墓碑列的表 —— 删除信号在整条链路上无处承载。delete_reading_note 从硬删改单事务软删四级(不再依赖 FK CASCADE:rusqlite 通道 PRAGMA foreign_keys 本就 OFF)。Supabase 侧 ALTER 已于 2026-08-03 先行 |
| v31 | word_encounters 表新建(候选词跨页复现计数,local-only)。roadmap 3-2 待发现词价值排序的第四维信号 —— 该维在本仓此前零数据源(word_page_links 只记已存词、reading_log 只存聚合)。聚合表非明细表(行数被候选池封顶 ≤~12k;明细表在重度用户身上是 ~1M 行/年);last_page_url 作去重闸挡 SPA 重跑(UPSERT 用 CASE WHEN last_page_url IS ?3 的 NULL-safe 相等);per-user 走 user_id 过滤沿 v27 先例、不进换用户清理,未登录时命令 no-op(不写 NULL user_id 行,避开「UNIQUE 里 NULL 互不相等」坑)。🔒 接受的近似:A→B→A 回访会重复计数 → UI 文案只许说「读到过 N 次」,不许说「N 篇文章」。不进同步(RVH 无浏览概念)。详见 docs/plans/archive/discovery-topn-ranking-plan.md |
| v32 | epub_positions 表新建(EPUB 章内滚动偏移,local-only)。backlog B4 剩余部分 —— 「恢复到章」早已零 migration 做完,章内没有现成载体(实测最长章可滚 47 屏)。存 block_idx + block_frac(结构量)而非像素:重排版实测把文档撑 +85%;也不存锚点 id —— 实测 Standard Ebooks 每章只有 1 个 id(等于只能恢复到章),而 <p> 密度反过来。新表而非 reading_pages 加列:后者的行只在存词/划线时产生,而本条场景恰是只读不存词;新表也保证不 touch last_opened_at → 「继续阅读」排序零变化。块枚举口径只有一处(content-script features/epub-anchor.js::collectPositionBlocks,存取共用;按 token 形式 [class^="rb-"],[class*=" rb-"] 排除自注入 UI —— 用 *="rb-" 会把正文 class="verb-list" 整块吞掉)。🔒 两道非显然的闸:① 保存要「用户动过」且「真滚了」才武装(只判前者会把 ⌘F 算进来、用章首盖掉真实位置;只判后者会把加载期原生锚点滚动算进来 —— 都是自我覆盖);② 章内落点经 __RB_EPUB_META.startPos 只捎带一次并立刻消费(该 meta 会因滚动联动 replaceState 反复重注入,不消费 = 每跨一个目录锚点把人拽回原处)。不进同步(RVH 无 EPUB 概念)。详见 docs/plans/archive/epub-intra-chapter-position-plan.md |
| v33 | epub_positions: ALTER ADD page_name(让「继续阅读」显示真正读到的那一章)。v32 把位置存下来了,但「继续阅读」那一行仍全部读 reading_pages,而那张表的行只在存词/划线时产生 —— 实测章节名和时间两个都错。章名存下来而不是现算,是因为由 source_ref 锚点反查章名需要那本书的目录(列表查询里让 Rust 去解压 EPUB 显然不行),而页内脚本存位置那一刻手上正好有现成的 effectiveSourceTitle()。只改显示不改成员资格:「继续阅读」仍只收存过词的书(engaged vs 全量导航的语义分野)。ALTER-ADD、local-only、不进同步、不动 schema.sql |
| v34 | vocabulary: CREATE INDEX idx_vocab_tagged ON vocabulary(word) WHERE word_tags IS NOT NULL —— 纯性能设施,服务专名治理 L-A(db/vocab_scope.rs 的「排除纯专名」判据,7 条查询共用)。加它的原因是那条判据实测把发现页热路径从 2ms 拖到 308ms:根因不是判据算得慢,是行读变贵 —— 原本走 idx_vocab_cefr 的索引覆盖扫描(word+cefr 索引里全有、不必读行),判据一碰 word_tags/pos_definitions 就每行读整行,而 pos_definitions 是整棵释义树、表 55MB(光 json_valid(pos_definitions) 一项 239ms)。三种纯 SQL 改写实测全在 270-300ms,瓶颈是行读取本身。本索引只覆盖 ~1102 行(全库 12291),让「算出那 584 个纯专名」变成小索引扫:dict 载入 308→65ms、发现候选池 150→28ms、排除集 278→20ms,索引体积 156KB。选索引而非物化列(ADD COLUMN learnable):物化 = 派生状态第二本账,reseed/backfill 忘记重算就静默过期且无报错;索引由 SQLite 自维护。迁移没跑成只是慢不是错(判据不依赖索引存在)→ 无需 init_db_state 兜底建表,与 v26 domain_zoom(缺列直接查询失败)不同。CREATE-INDEX、local-only、不进同步、不动 schema.sql |
压平记录(开发期策略:不保留中间 migration;重装 / 删库后直接从 v1 baseline 创建终态 schema。历史细节见 git log):
- 2026-04-12:snapshot 之前的 v17-v35 增量合并进 v1 baseline。
- 2026-04-21:阶段 B/C 引入的 v4-v8 增量(drop word_contexts / source_type 回填 / storage_path / reference_words 重建 / favorite_sites+rss+domain_prefs 重建)吸收到 v1 baseline。