主题
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 | :435 | ✅ WHERE user_id = ?1 AND ((...)) | :447 同上 |
favorite_sites | :517 | ✅ WHERE user_id = ?1 AND ((...)) | — |
同文件里已经有两个写对的,连括号都对 —— 说明这是不一致而非设计取舍,修的时候直接抄 page_annotations(:435) 的写法即可。
2. 为什么 RB 当前大概率没在泄漏(三道防线,逐条 grep 过)
- 清库条件更强:
commands/auth.rs::clear_learning_data_if_user_changed用WHERE user_id IS NULL OR user_id != ?1—— 连 NULL 游客行一起清。 对比 RVH 是WHERE user_id = ?,NULL 行任何用户都删不掉、永久残留 —— 那正是 RVH 那条不依赖任何竞态的确定性触发路径,RB 没有。 - 清库是硬闸:
save_session里clear_learning_data_if_user_changed(conn, &session.user_id)?排在写auth_user_id之前,清库失败 →?上抛 → session 不落库 → 登录失败。 不存在「以新用户身份先 push、清库随后才跑」的窗口(RVH 是广播先行 + rethrow 后无人接)。 - 产生不了 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 里 AND 比 OR 结合更紧。漏括号会退化成:
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.getAllForUser 用 user_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_links 有 7 个 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用 mocktailimplements假件交出真实 DDL 建的 ffi 库,SupabaseSyncDatasource换成捕获式假件记录实际 payload,断言 push 出去的user_id。 RB 侧若有等价的可注入 HTTP 层,建议同样断言 payload 实际内容而非只测 SQL。 - RVH 实测复现数据:6 张表各放 1 行 userA + 1 行 userB 的脏行,以 userB 身份同步 →
pushed=12,12 行全部带user_id=userB上行。
6. 完成后请回写
修完请在 RB 侧 CLAUDE.md 立对应红线,并回一份确认到本目录(编号 21,参考 17/18 的 confirmation 体例),注明:
- 6 个函数是否都改了、括号是否都加了
= ?1/IS ?1的选择及其依据(RB 读侧实际过滤方式)- §4.4
word_page_links7 个 INSERT 点的核实结论 - 回归测试覆盖了哪些场景(特别是「已同步后又变脏的外来行」这一组)