Skip to content

20 — push dirty-check 按 user_id 过滤:RVH 已落地 + RB 侧待办交接

物理位置:本文件在 RVH 仓库 ~/reading_vocab_helper/docs/cross-end/20-rb-push-user-scoping-handoff.md。 本次 RVH 先行,RB 侧待镜像(同 19 的方向)。

创建:2026-08-05 · 关联 RVH 红线 #5h(v59)· 不涉及 schema 变更 起因:RVH 修 #5h 时在交接单里留了「RB 侧待核」,2026-08-05 实测核完 —— 结论与当时的猜测相反。


0. 一句话结论

RB push.rs 的 8 个 push 里 6 个存在与 RVH 完全相同的缺陷:dirty-check 不按 user_id 过滤,payload 却把 user_id 写成 config.user_id但 RB 当前大概率没有在泄漏 —— 它的三道 防线都比 RVH 强。所以这是一次防御性修复,不是止血。

⚠️ RVH 红线 #5h 早先写的「RB 是单用户 profile 目录模型,可能天然不受影响」已证伪:RB 的库是 app_data_dir()/lampio.db 的共享单库,且有 clear_learning_data_if_user_changed,与 RVH 同模型。


1. 实测证据(全部 grep 过,非推断;RB @ 2c3c871

src-tauri/src/commands/sync/push.rs

dirty-check 行是否按 user_id 过滤payload
learning_entries:85❌ 无:93 "user_id": &config.user_id
reading_notes:139❌ 无:151 同上
reading_pages:189❌ 无:198 同上
word_page_links:349❌ 无:358 同上
word_cloze_contexts:393❌ 无:402 同上
known_words:479❌ 无:487 同上
page_annotations:435WHERE user_id = ?1 AND ((...)):447 同上
favorite_sites:517WHERE user_id = ?1 AND ((...))

同文件里已经有两个写对的,连括号都对 —— 说明这是不一致而非设计取舍,修的时候直接抄 page_annotations(:435) 的写法即可。


2. 为什么 RB 当前大概率没在泄漏(三道防线,逐条 grep 过)

  1. 清库条件更强commands/auth.rs::clear_learning_data_if_user_changedWHERE user_id IS NULL OR user_id != ?1 —— 连 NULL 游客行一起清。 对比 RVH 是 WHERE user_id = ?,NULL 行任何用户都删不掉、永久残留 —— 那正是 RVH 那条不依赖任何竞态的确定性触发路径,RB 没有。
  2. 清库是硬闸save_sessionclear_learning_data_if_user_changed(conn, &session.user_id)? 排在写 auth_user_id 之前,清库失败 → ? 上抛 → session 不落库 → 登录失败。 不存在「以新用户身份先 push、清库随后才跑」的窗口(RVH 是广播先行 + rethrow 后无人接)。
  3. 产生不了 NULL 行:登出走 clear_user_learning_data 全量清;写入路径统一用 commands/user_ctx.rs::current_user_id(&conn)?,它在 settings.auth_user_id 缺失时返回 Err(NotConfigured) → 写入直接失败,而不是落一个 NULL。

3. 那为什么还要修

  • schema 层没有兜底src-tauri/assets/sql/schema.sql 里这 8 张表的 user_id 全部可空 (逐表确认过;RVH 只有 known_words 可空)。唯一屏障是「所有写入都走 current_user_id」 这条运行期约定,不是约束。
  • 约定一旦破一次就是静默跨用户泄漏:加游客/离线模式、或某个新写入路径漏填 user_id, push 就会把它认领成当前用户的数据传到云端 —— 且没有任何报错。
  • 修复成本极低、零行为变化:6 处加一个 WHERE user_id = ?1 + 括号,同文件有现成模板。

4. 修复要求

4.1 改法

6 个函数的 dirty-check 加 WHERE user_id = ?1,并把原有 dirty 条件整体括起来, 参数列表相应绑上 &config.user_id。抄 page_annotations(:435) 的形状:

rust
"SELECT ... FROM <table>
 WHERE user_id = ?1
   AND ((synced_at IS NULL) \
        OR (updated_at IS NOT NULL AND updated_at > synced_at) \
        OR (deleted_at IS NOT NULL AND (synced_at IS NULL OR deleted_at > synced_at)))"

4.2 ⚠️ 括号是硬要求(这次改动最危险的地方)

SQL 里 ANDOR 结合更紧。漏括号会退化成:

sql
(user_id = ?1 AND synced_at IS NULL) OR updated_at > synced_at OR ...

—— 「改过」「软删」两个分支完全绕开 user_id 过滤,比不加过滤还糟

且「新增行」(synced_at IS NULL)抓不到这个错法(NULL 参与比较得 unknown,第二个析取项 不成立)。RVH 侧实测:注入漏括号缺陷后,只测新增行的那一组 13 个用例全部照过,只有专门 造「已同步过、之后又被改 / 被软删」的外来行那一组才红。回归测试必须造后者。

4.3 ⚠️ = ?1 还是 IS ?1:先看 RB 自己的读侧,别照抄 RVH

RVH 选 = ?(即不认领 user_id IS NULL 的行),依据是 RVH 读侧 LocalKnownWordsDataSource.getAllForUseruser_id IS ? —— 真实 userId 匹配不上 NULL 行, 这些行当前用户根本看不见,看不见的数据不该以其身份上行。

RB 的读侧过滤是什么样、NULL 行在 RB 里是否可见,我没有核实。请先 grep RB 自己的读路径再定:

  • 若 RB 读侧也看不见 NULL 行 → 用 = ?1,与 RVH 同语义
  • 若 RB 读侧能看见(例如用 IS ?1 或不过滤)→ 用 = ?1 会让这些行变成「UI 里看得见但永远同步 不上去」,需要单独权衡

⚠️ 无论选哪个,都不要"顺手"把 NULL 行回填成当前 user_id 再推 —— 那是主动把来路不明的数据 算作自己的,方向相反。

4.4 未验证项(请务必自己核,别当结论)

word_page_links7 个 INSERT 点,而我 grep 只在 3 处附近看到 user_id。可能只是 grep 窗口太窄或那几处是 pull 路径,但这是唯一可能产生 NULL 行的具体嫌疑点 —— 如果真有写入路径 漏填 user_id,那么第 2 节「产生不了 NULL 行」的结论就不成立,本条的优先级要从「防御性」上调。 请逐个确认。

4.5 顺带建议核对

RB 是否有等价于 RVH getSyncStatus 的「待推送计数」读数。RVH 侧发现该计数与 push dirty-check 口径不一致会导致「push 推不走、UI 却一直显示待同步」的清不掉的计数;修 push 时必须同步跟改。


5. RVH 侧已落地内容(供参考对照)

  • 红线 #5h~/reading_vocab_helper/CLAUDE.md「跨端 Sync 协议红线」段,含 awk 版 CI grep (逐行校验「WHERE user_id = ? 的下一行必须以 AND ( 开头」;不要grep -c 'AND (synced_at IS NULL' 这种松匹配 —— 墓碑子句自带这串,RVH 实测灌到 21 命中, 删掉外层括号照样"通过")。
  • 回归测试:test/features/sync/push_user_scoping_test.dart(19 例)。做法是驱动真实syncNow()AppDatabase 用 mocktail implements 假件交出真实 DDL 建的 ffi 库, SupabaseSyncDatasource 换成捕获式假件记录实际 payload,断言 push 出去的 user_id。 RB 侧若有等价的可注入 HTTP 层,建议同样断言 payload 实际内容而非只测 SQL。
  • RVH 实测复现数据:6 张表各放 1 行 userA + 1 行 userB 的脏行,以 userB 身份同步 → pushed=1212 行全部带 user_id=userB 上行

6. 完成后请回写

修完请在 RB 侧 CLAUDE.md 立对应红线,并回一份确认到本目录(编号 21,参考 17/18 的 confirmation 体例),注明:

  • 6 个函数是否都改了、括号是否都加了
  • = ?1 / IS ?1 的选择及其依据(RB 读侧实际过滤方式)
  • §4.4 word_page_links 7 个 INSERT 点的核实结论
  • 回归测试覆盖了哪些场景(特别是「已同步后又变脏的外来行」这一组)