fix(同步): 修复删除 404、远端覆盖本地修改、404 残留无法清理 #6

采纳社区反馈(小褚的 fork)中的 3 处高危修复。均为纯逻辑修复,
不改 UI 架构、不动状态管理范式。

## 1. remotePath 用服务器真实文件名(删除/推送全链路)

### 缺陷
上传/删除 URL 里的资源名来自 `remotePath`,而它一直被硬编码成
`encodeURIComponent(uid) + '.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` 增加第三个参数,
   传当前 href 的真实文件名。不补这里,前一条等于白做 ——
   因为大部分资源 etag 不变、根本不走 GET。
4. `applyRemote` 落库:优先用 `r.remotePath`,为空才退回按 uid 猜
   (本地新建等服务器上还没有资源的场景)。
5. 更新分支同步 `found.remotePath`:服务器上资源改名后要跟着改,
   否则后续 DELETE/PUT 仍打旧路径。

## 2. applyRemote 加 dirty/deleted 白名单(防复活)

### 缺陷
原来只有 `if (e.dirty) continue`(dirty 行不入 keyed 索引)。
**只排除出索引不够**:远端数据 `keyed.get(key)` 拿不到 → 走"新增"分支
→ 又插一行。结果同一事件在本地有两行(用户改的 + 远端旧版),
用户看到"改完又变回去"或"出现两条"。

### 修法(补两层)
- `dirtyKeyed`:dirty 行单独索引,远端同 key 出现时直接 `continue`,
  保留本地版本,不新增不覆盖。
- `dirtyDeletedUids`:本地已标记删除的 uid 集合,远端该 uid 的任何版本
  一律忽略。**按 uid 而非 key** —— 删重复系列时本地只留主事件标 deleted,
  但远端还有一串 RECURRENCE-ID 实例行,key 各不相同,只有 uid 能整族挡住。

(`markDeleted` 同时置 `deleted=1` 和 `dirty=1`,故 `dirty && deleted` 可同时覆盖。)

## 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`
- 静态交叉核查:
  - `rowToRemote` 唯一调用处已补第三参数
  - `getDirty` 只查 `dirty=1`,deleted 行(双置)仍会被推送,无漏推
  - `purge` / `purgeByUid` 均为 `store.delete`,确认是物理删除
  - 剩余 3 处 `encodeURIComponent(uid)+'.ics'`(MirrorInbound 247、
    SystemCalendarImport 99、EventEditPage 1088)属**本地新建**场景,
    此时服务器上尚无资源,用 uid 作初始文件名是正确行为;
    PUT 一旦成功,该 URL 即成为资源真名,故无需再改。
- ⚠️ **真机未验证**:设备当前未连接(hdc list targets 返回空),
  需连上后按下列步骤验证。

## 待真机验证项
1. 新建日程 → 同步 → 在应用内删除 → 服务器端确认已消失
   (重点验服务器实际文件名 ≠ uid 的账号,如家书)
2. 修改日程标题 → 不手动同步 → 等自动同步 → 标题应保持修改后的值
3. 删除重复日程整个系列 → 服务器端确认整族消失,本地无残留
4. 若服务器上资源已被外部删除,应用内删除该日程应能正常清掉本地行
This commit is contained in:
2026-11-03 07:36:06 +08:00
parent 23b5d4cb9e
commit 9a67fce296
2 changed files with 99 additions and 11 deletions
+41 -4
View File
@@ -56,6 +56,15 @@ export class RemoteEvent {
recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒;0 = 无)
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段)
reminders: number[] = []; // 全部提醒提前分钟数(多选)
/**
* ⭐ REMOTE_PATH 服务器上的真实资源文件名(REPORT 返回的 href 末段,已含 URL 编码)。
*
* 为什么不能用 uid 拼:服务器不保证「资源文件名 = ICS 里的 UID」。
* 家书 CalDav.php 用 `preg_replace('/[^A-Za-z0-9._-]/', '_', $uid)` 生成文件名,
* UID `movie-188@familybook` 的真实资源是 `movie-188_familybook.ics`。
* 猜错 ⇒ DELETE/PUT 打到不存在的路径 ⇒ 404,服务器上删不掉、或写出重复资源。
*/
remotePath: string = '';
}
export class EventDb {
@@ -524,14 +533,29 @@ export class EventDb {
// 原先对每条都 `existing.find(...)` ⇒ O(n·m)(工作本 735 行 → 54 万次比较,
// 每次还要现拼 `uid_startTime_recId` 字符串),2184 行全库累计近 100 万次。
// 卡片后台同步的时间预算只有 9 秒,这笔开销吃不起 → 预先建 Map,改成 O(1) 查。
// 语义与 `existing.find(x => !x.dirty && x.uid===r.uid && x.startTime===r.startTime
// && x.recurrenceId===r.recurrenceId)` 完全一致:dirty 行不入索引、同键取**最先**出现的那行。
// dirty 行不入 `keyed`、同键取**最先**出现的那行。
// ⭐ 但仅排除出 keyed不够(见下方 REVIVE_GUARD):还需单独索引 dirty 行并主动拦截,
// 否则远端数据查不到 key 会走新增分支,反而插出重复行。
const keyed: Map<string, LocalEvent> = new Map<string, LocalEvent>();
// ⭐ REVIVE_GUARD dirty 行**单独**建索引,而不是只从 keyed 里排除。
// 只排除不够:远端数据 keyed 查不到 → 走"新增"分支 → 又插一行,
// 本地就出现两行(用户改的 + 远端旧版),表现为"改完又变回去/出现两条"。
const dirtyKeyed: Map<string, LocalEvent> = new Map<string, LocalEvent>();
// ⭐ 本地已标记删除、还没推给服务器的 uid 集合。
// 按 uid 而非 key:删重复系列时本地只留主事件标 deleted,
// 但远端还有一串 RECURRENCE-ID 实例行,key 各不相同,只有 uid 能整族挡住。
const dirtyDeletedUids: Set<string> = new Set<string>();
for (const e of existing) {
const k: string = `${e.uid}_${e.startTime}_${e.recurrenceId}`;
if (e.dirty && e.deleted) {
dirtyDeletedUids.add(e.uid);
}
if (e.dirty) {
if (!dirtyKeyed.has(k)) {
dirtyKeyed.set(k, e);
}
continue;
}
const k: string = `${e.uid}_${e.startTime}_${e.recurrenceId}`;
if (!keyed.has(k)) {
keyed.set(k, e);
}
@@ -548,6 +572,14 @@ export class EventDb {
continue; // 同一资源内的重复条目 → 只保留一条
}
seenKeys.add(key);
// ⭐ REVIVE_GUARD 本地已删待推送 → 远端旧版本一律忽略,防止死灰复燃
if (dirtyDeletedUids.has(r.uid)) {
continue;
}
// ⭐ 本地有未推送的修改 → 保留本地版本,绝不能让远端旧版覆盖或重复插入
if (dirtyKeyed.has(key)) {
continue;
}
const found: LocalEvent | undefined = keyed.get(key);
if (found === undefined) {
// 新增
@@ -555,7 +587,8 @@ export class EventDb {
e.uid = r.uid;
e.calKey = calKey;
e.href = href;
e.remotePath = encodeURIComponent(r.uid) + '.ics';
// ⭐ REMOTE_PATH 优先用服务器真实文件名;为空时才退回按 uid 猜(本地新建等场景)
e.remotePath = r.remotePath !== '' ? r.remotePath : (encodeURIComponent(r.uid) + '.ics');
e.title = r.title;
e.description = r.description;
e.location = r.location;
@@ -584,6 +617,10 @@ export class EventDb {
// 行里若残留旧 URL,用户下次编辑该日程会 PUT 到已不存在的集合(404),
// 且 pushDirty 按 href 过滤会漏掉它导致永远推不出去)
found.href = href;
// 服务器上资源被改名时同步更新,否则后续 DELETE/PUT 会打到旧路径
if (r.remotePath !== '' && found.remotePath !== r.remotePath) {
found.remotePath = r.remotePath;
}
found.title = r.title;
found.description = r.description;
found.location = r.location;
+58 -7
View File
@@ -244,8 +244,9 @@ export class SyncEngine {
if (!fullRefetch && group !== undefined && group.length > 0 && !group[0].dirty
&& group[0].etag === it.etag) {
// etag 未变:直接用本地行还原远端数据(含 reminder 等完整字段)
const rp: string = it.href.substring(it.href.lastIndexOf('/') + 1);
for (const row of group) {
remote.push(SyncEngine.rowToRemote(row, it.etag));
remote.push(SyncEngine.rowToRemote(row, it.etag, rp));
}
continue;
}
@@ -264,6 +265,12 @@ export class SyncEngine {
return [];
}
const resUid: string = SyncEngine.resUidFromHref(it.href);
// ⭐ REMOTE_PATH 服务器真实资源文件名(href 末段,已含 URL 编码)。
// 绝不能用 `encodeURIComponent(uid) + '.ics'` 去猜:
// 家书 CalDav.php 用 preg_replace('/[^A-Za-z0-9._-]/','_',uid) 生成文件名,
// UID `movie-188@familybook` 的真实资源是 `movie-188_familybook.ics`。
// 猜错 ⇒ DELETE/PUT 打到不存在的路径 ⇒ 404,服务器上删不掉、或写出重复资源。
const fileName: string = it.href.substring(it.href.lastIndexOf('/') + 1);
const parsed: RemoteEvent[] = IcsUtil.parse(raw);
const out: RemoteEvent[] = [];
for (const r of parsed) {
@@ -273,6 +280,7 @@ export class SyncEngine {
if (r.uid === '') {
r.uid = resUid;
}
r.remotePath = fileName;
r.etag = it.etag;
out.push(r);
}
@@ -506,10 +514,18 @@ export class SyncEngine {
}
/** 本地行 → 远端事件(etag 未变的资源复用本地完整数据,含 reminder) */
private static rowToRemote(e: LocalEvent, etag: string): RemoteEvent {
/**
* ⭐ REMOTE_PATH 第三个参数是当前 REPORT 返回的真实资源文件名(href 末段)。
*
* 为什么必须从外面传进来:本地行的 remotePath 可能是历史数据用 uid 猜出来的,
* 也可能是空的(本地新建后还没同步成功)。而 REPORT 的 href 反映的是
* **此刻服务器上的真实文件名**,比本地任何值都可靠。
*/
private static rowToRemote(e: LocalEvent, etag: string, remotePath: string): RemoteEvent {
const r = new RemoteEvent();
r.uid = e.uid;
r.etag = etag;
r.remotePath = remotePath !== '' ? remotePath : e.remotePath;
r.title = e.title;
r.description = e.description;
r.location = e.location;
@@ -564,7 +580,27 @@ export class SyncEngine {
const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`;
try {
if (e.deleted) {
await DavClient.deleteRemote(url, auth);
// ⭐ DELETE_404_PURGE 单条路径:deleteRemote 内部已把 404 视作成功,
// 这里再显式兜一层 410(Gone),并保证无论哪种"远端已无"都清本地。
let gone: boolean = false;
try {
await DavClient.deleteRemote(url, auth);
gone = true;
} catch (delErr) {
if (delErr instanceof DavHttpError) {
const st: number = (delErr as DavHttpError).status;
if (st === 401) {
throw delErr as Error;
}
if (st === 404 || st === 410) {
gone = true;
LogUtil.write(`单条资源在服务器上已不存在(${st}),直接清理本地:uid=${e.uid}`);
}
}
if (!gone) {
throw delErr as Error;
}
}
await EventDb.purge(context, e.id);
LogUtil.write(`推送删除(标题已脱敏)uid=${e.uid} → ${url}`);
} else {
@@ -616,14 +652,29 @@ export class SyncEngine {
const url: string = href.endsWith('/') ? href + path : `${href}/${path}`;
// 整个系列被删除(主事件行 deleted)→ DELETE 资源 + 清掉该 uid 全部本地行
if (master.deleted) {
// ⭐ DELETE_404_PURGE 404/410 = 服务器上本就没有这个资源。
// 必须当成"删除成功"继续往下清本地,不能 return ——
// 否则本地行永远留着,用户看到"删不掉的日程",且每次同步都重复失败。
// 典型诱因:remotePath 用 uid 猜错(服务器真实文件名不同)⇒ DELETE 打到不存在的路径。
let remoteGone: boolean = false;
try {
await DavClient.deleteRemote(url, auth);
remoteGone = true;
} catch (err) {
if (err instanceof DavHttpError && (err as DavHttpError).status === 401) {
throw err as Error;
if (err instanceof DavHttpError) {
const st: number = (err as DavHttpError).status;
if (st === 401) {
throw err as Error;
}
if (st === 404 || st === 410) {
remoteGone = true;
LogUtil.write(`重复系列资源在服务器上已不存在(${st}),直接清理本地:uid=${uid}`);
}
}
if (!remoteGone) {
LogUtil.write(`推送删除重复系列失败(保留待推送):uid=${uid} → ${(err as Error).message}`);
return;
}
LogUtil.write(`推送删除重复系列失败:uid=${uid} → ${(err as Error).message}`);
return;
}
await EventDb.purgeByUid(context, calKey, uid);
LogUtil.write(`推送删除重复系列(标题已脱敏)uid=${uid} → ${url}`);