同步核心修复:删除 404、远端覆盖本地修改、404 残留无法清理 #8

Closed
opened 2026-11-03 01:05:25 +00:00 by laoyang · 1 comment
Owner

背景

采纳社区反馈(小褚的 fork)中的 3 处高危修复。均为纯逻辑修复,不改 UI 架构。

对应提交:9a67fce(2 文件,+99 / -11)


修复 1:remotePath 用服务器真实文件名

缺陷

上传/删除 URL 里的资源名来自 remotePath,而它一直被硬编码成
encodeURIComponent(uid) + '.ics' —— 猜的。

服务器不保证「文件名 = ICS 里的 UID」:

  • 家书 CalDav.php 用 preg_replace('/[^A-Za-z0-9._-]/', '_', $uid) 生成文件名
  • UID movie-188@familybook 的真实资源是 movie-188_familybook.ics

猜错 ⇒ DELETE 打到不存在的路径 ⇒ 404 ⇒ 服务器上删不掉(用户看到"删了又回来");
PUT 同理会写到另一个文件名下,造成同一事件两个资源。

修法(三条链路都覆盖)

  1. RemoteEvent 新增 remotePath 字段
  2. GET 补拉:r.remotePath = fileName(从 REPORT 的 href 末段取)
  3. etag 未变复用(常态路径,仅改前两处不够):rowToRemote 增加第三参数
  4. unchanged 分支:etag 未变时只校正 href、不碰 remotePath ⇒ 存量数据永远不纠正
  5. applyRemote 落库优先用真名;更新分支同步 found.remotePath

第 3、4 条是关键:REPORT 只返回 etag,未变就复用本地行、不 GET。
实测 99% 的行走这条 ⇒ 只改 GET 分支对存量数据等于无效。


修复 2:applyRemote 加 dirty/deleted 白名单(防复活)

缺陷

原来只有 if (e.dirty) continue(dirty 行不入 keyed 索引)。
只排除出索引不够:远端数据 keyed.get(key) 查不到 → 走"新增"分支
→ 又插一行。结果同一事件本地有两行(用户改的 + 远端旧版)。

修法

  • dirtyKeyed:dirty 行单独索引,远端同 key 出现时直接跳过
  • dirtyDeletedUids:本地已删待推送的 uid 集合,远端该 uid 任何版本一律忽略
    —— 按 uid 而非 key:删重复系列时本地只留主事件标 deleted,
    但远端还有一串 RECURRENCE-ID 实例行,key 各不相同,只有 uid 能整族挡住

修复 3:DELETE 404/410 物理清库

缺陷(真正的坑在 pushSeries)

DavClient.deleteRemote 已把 404 视作成功,但系列删除路径仍踩坑:

if (master.deleted) {
  try { await DavClient.deleteRemote(url, auth); }
  catch (err) {
    ...401 抛出...
    LogUtil.write(`推送删除重复系列失败:...`);
    return;            // ⭐ 404/410 也走到这里 → 直接 return
  }
  await EventDb.purgeByUid(context, calKey, uid);   // ← 永远执行不到
}

URL 拼错(正是修复 1 的 bug 造成)或服务器上资源本就不在 ⇒ DELETE 返回 404
⇒ 进 catch ⇒ return,不清本地 ⇒ 用户看到"删不掉的日程",每次同步重复失败。

修法

单条 + 系列两条路径都区分状态码:

  • 401 → 抛出(凭据失效,后续每条都会失败)
  • 404 / 410 → 服务器上本就没有,视为删除成功,继续清理本地
  • 其它 → 真失败,保留待推送

编译状态

BUILD SUCCESSFUL(hvigor,hAP 调试包)

## 背景 采纳社区反馈(小褚的 fork)中的 3 处高危修复。均为纯逻辑修复,不改 UI 架构。 对应提交:`9a67fce`(2 文件,+99 / -11) --- ## 修复 1:remotePath 用服务器真实文件名 ### 缺陷 上传/删除 URL 里的资源名来自 `remotePath`,而它一直被硬编码成 `encodeURIComponent(uid) + '.ics'` —— **猜的**。 服务器不保证「文件名 = ICS 里的 UID」: - 家书 CalDav.php 用 `preg_replace('/[^A-Za-z0-9._-]/', '_', $uid)` 生成文件名 - UID `movie-188@familybook` 的真实资源是 `movie-188_familybook.ics` 猜错 ⇒ DELETE 打到不存在的路径 ⇒ 404 ⇒ **服务器上删不掉**(用户看到"删了又回来"); PUT 同理会写到另一个文件名下,造成同一事件两个资源。 ### 修法(三条链路都覆盖) 1. `RemoteEvent` 新增 `remotePath` 字段 2. **GET 补拉**:`r.remotePath = fileName`(从 REPORT 的 href 末段取) 3. **etag 未变复用**(常态路径,仅改前两处不够):`rowToRemote` 增加第三参数 4. **unchanged 分支**:etag 未变时只校正 href、不碰 remotePath ⇒ 存量数据永远不纠正 5. `applyRemote` 落库优先用真名;更新分支同步 `found.remotePath` > 第 3、4 条是关键:REPORT 只返回 etag,未变就复用本地行、不 GET。 > 实测 99% 的行走这条 ⇒ 只改 GET 分支对存量数据等于无效。 --- ## 修复 2:applyRemote 加 dirty/deleted 白名单(防复活) ### 缺陷 原来只有 `if (e.dirty) continue`(dirty 行不入 keyed 索引)。 **只排除出索引不够**:远端数据 `keyed.get(key)` 查不到 → 走"新增"分支 → 又插一行。结果同一事件本地有两行(用户改的 + 远端旧版)。 ### 修法 - `dirtyKeyed`:dirty 行单独索引,远端同 key 出现时直接跳过 - `dirtyDeletedUids`:本地已删待推送的 uid 集合,远端该 uid 任何版本一律忽略 —— **按 uid 而非 key**:删重复系列时本地只留主事件标 deleted, 但远端还有一串 RECURRENCE-ID 实例行,key 各不相同,只有 uid 能整族挡住 --- ## 修复 3:DELETE 404/410 物理清库 ### 缺陷(真正的坑在 pushSeries) `DavClient.deleteRemote` 已把 404 视作成功,但系列删除路径仍踩坑: ```ts if (master.deleted) { try { await DavClient.deleteRemote(url, auth); } catch (err) { ...401 抛出... LogUtil.write(`推送删除重复系列失败:...`); return; // ⭐ 404/410 也走到这里 → 直接 return } await EventDb.purgeByUid(context, calKey, uid); // ← 永远执行不到 } ``` URL 拼错(正是修复 1 的 bug 造成)或服务器上资源本就不在 ⇒ DELETE 返回 404 ⇒ 进 catch ⇒ **return,不清本地** ⇒ 用户看到"删不掉的日程",每次同步重复失败。 ### 修法 单条 + 系列两条路径都区分状态码: - 401 → 抛出(凭据失效,后续每条都会失败) - **404 / 410 → 服务器上本就没有,视为删除成功,继续清理本地** - 其它 → 真失败,保留待推送 --- ## 编译状态 `BUILD SUCCESSFUL`(hvigor,hAP 调试包)
Author
Owner

✅ 真机验证通过

设备:5NC0225B27004718(HarmonyOS)
服务器:DAViCal 1.1.9 @ nas.yangyq.net/caldav.php/synccalendartest


关键发现:这台服务器天然测不出该 bug

DAViCal 的命名规则是「把 uid 做 URL 编码后直接当文件名」:

uid    : 20261102T153543-10c5d3cb@169.254.186.221
文件名 : 20261102T153543-10c5d3cb%40169.254.186.221.ics

猜的名字 == 真名,同步 746 条日程后 remotePath 全部等于 uid 猜测值,
无论修复是否生效都看不出差异。

所以必须造一个「文件名 ≠ UID」的资源才能验出来。


造陷阱资源

直接在服务器上 PUT 一个刻意不匹配的资源:

文件名 : trap-file.ics
UID    : trap-uid-with-special-chars@example.com

服务器上两个路径的实际状态(这是核心证据):

路径 状态
tfxnz/trap-file.ics(真实文件名) 200 存在
tfxnz/trap-uid-with-special-chars%40example.com.ics(旧代码会拼的地址) 404 不存在

⇒ 旧代码删除时必然打到 404。


验证 1:remotePath 存的是真名 ✅

应用同步后,本地库该行为:

项 值
uid trap-uid-with-special-chars@example.com
remotePath(实际存储) trap-file.ics ✅
旧代码会存成 trap-uid-with-special-chars%40example.com.ics

验证 2:删除打到正确地址并成功 ✅

在应用内对该日程执行「编辑 → 删除日程 → 确认删除」,实测日志:

DELETE https://nas.yangyq.net/caldav.php/synccalendartest/tfxnz/trap-file.ics 响应码: 204
SyncLog HTTP DELETE https://nas.yangyq.net/caldav.php/synccalendartest/tfxnz/trap-file.ics → 204
SyncLog 推送删除(标题已脱敏)uid=trap-uid-with-special-chars@example.com → https://nas.yangyq.net/caldav.php/synccalendartest/tfxnz/trap-file.ics
SyncLog 本地删除日程(标题已脱敏)uid=trap-uid-with-special-chars@example.com 范围=all

用的是 trap-file.ics,HTTP 204 成功。


验证 3:服务器端确实删掉了 ✅

trap-file.ics                    → 404(已删除)
REPORT tfxnz 资源数               → 0

验证 4:本地物理清库 ✅

本地库 trap 行数 → 0

服务器删了、本地也清了,无残影。


验证 5:防复活白名单未误伤 ✅

同步日志:

日历本[5]「家庭计划」日程落库完成:新增0 更新0 删除0 不变1

同 uid 在库里始终 1 行 — 没有因为「查不到就新增」而插出重复行。


完整同步结果

日历本 行数
home(娱乐) 716
lvirlu(工作) 9
ycbco(家庭) 7
cojiujq(生活) 7
tfxnz(家庭计划) 3
zlqlzm(工作任务) 2
tdotbsnl(个人成长) 2
合计 746

⚠️ 踩坑记录:拉库必须连 WAL

第一次拉数据库读到 735 行、remotePath 全是 uid 猜测值,差点误判修复失效。

真实原因:WAL 未合并。

sync_calendar.db      344 KB
sync_calendar.db-wal  428 KB   ← 最新数据全在这里

只拉主库会看到过期快照。必须连 -wal、-shm 一起拉再查询。


结论

3 条修复全部验证通过。陷阱数据已彻底清理(服务器 404、本地 0 行),测试日历无残留。

## ✅ 真机验证通过 设备:`5NC0225B27004718`(HarmonyOS) 服务器:DAViCal 1.1.9 @ `nas.yangyq.net/caldav.php/synccalendartest` --- ## 关键发现:这台服务器天然测不出该 bug DAViCal 的命名规则是「把 uid 做 URL 编码后直接当文件名」: ``` uid : 20261102T153543-10c5d3cb@169.254.186.221 文件名 : 20261102T153543-10c5d3cb%40169.254.186.221.ics ``` **猜的名字 == 真名**,同步 746 条日程后 `remotePath` 全部等于 uid 猜测值, 无论修复是否生效都看不出差异。 所以必须造一个「文件名 ≠ UID」的资源才能验出来。 --- ## 造陷阱资源 直接在服务器上 PUT 一个刻意不匹配的资源: ``` 文件名 : trap-file.ics UID : trap-uid-with-special-chars@example.com ``` 服务器上两个路径的实际状态(这是核心证据): | 路径 | 状态 | |---|---| | `tfxnz/trap-file.ics`(真实文件名) | **200 存在** | | `tfxnz/trap-uid-with-special-chars%40example.com.ics`(旧代码会拼的地址) | **404 不存在** | ⇒ 旧代码删除时必然打到 404。 --- ## 验证 1:remotePath 存的是真名 ✅ 应用同步后,本地库该行为: | 项 | 值 | |---|---| | `uid` | `trap-uid-with-special-chars@example.com` | | `remotePath`(实际存储) | **`trap-file.ics`** ✅ | | 旧代码会存成 | `trap-uid-with-special-chars%40example.com.ics` | --- ## 验证 2:删除打到正确地址并成功 ✅ 在应用内对该日程执行「编辑 → 删除日程 → 确认删除」,实测日志: ``` DELETE https://nas.yangyq.net/caldav.php/synccalendartest/tfxnz/trap-file.ics 响应码: 204 SyncLog HTTP DELETE https://nas.yangyq.net/caldav.php/synccalendartest/tfxnz/trap-file.ics → 204 SyncLog 推送删除(标题已脱敏)uid=trap-uid-with-special-chars@example.com → https://nas.yangyq.net/caldav.php/synccalendartest/tfxnz/trap-file.ics SyncLog 本地删除日程(标题已脱敏)uid=trap-uid-with-special-chars@example.com 范围=all ``` 用的是 `trap-file.ics`,**HTTP 204 成功**。 --- ## 验证 3:服务器端确实删掉了 ✅ ``` trap-file.ics → 404(已删除) REPORT tfxnz 资源数 → 0 ``` --- ## 验证 4:本地物理清库 ✅ ``` 本地库 trap 行数 → 0 ``` 服务器删了、本地也清了,无残影。 --- ## 验证 5:防复活白名单未误伤 ✅ 同步日志: ``` 日历本[5]「家庭计划」日程落库完成:新增0 更新0 删除0 不变1 ``` 同 uid 在库里始终 1 行 — 没有因为「查不到就新增」而插出重复行。 --- ## 完整同步结果 | 日历本 | 行数 | |---|---| | home(娱乐) | 716 | | lvirlu(工作) | 9 | | ycbco(家庭) | 7 | | cojiujq(生活) | 7 | | tfxnz(家庭计划) | 3 | | zlqlzm(工作任务) | 2 | | tdotbsnl(个人成长) | 2 | | **合计** | **746** | --- ## ⚠️ 踩坑记录:拉库必须连 WAL 第一次拉数据库读到 735 行、`remotePath` 全是 uid 猜测值,**差点误判修复失效**。 真实原因:**WAL 未合并**。 ``` sync_calendar.db 344 KB sync_calendar.db-wal 428 KB ← 最新数据全在这里 ``` 只拉主库会看到过期快照。必须连 `-wal`、`-shm` 一起拉再查询。 --- ## 结论 3 条修复全部验证通过。陷阱数据已彻底清理(服务器 404、本地 0 行),测试日历无残留。
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: laoyang/SyncCalendar#8