diff --git a/entry/src/main/ets/common/EventDb.ets b/entry/src/main/ets/common/EventDb.ets index a11d597..c676aae 100644 --- a/entry/src/main/ets/common/EventDb.ets +++ b/entry/src/main/ets/common/EventDb.ets @@ -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 = new Map(); + // ⭐ REVIVE_GUARD dirty 行**单独**建索引,而不是只从 keyed 里排除。 + // 只排除不够:远端数据 keyed 查不到 → 走"新增"分支 → 又插一行, + // 本地就出现两行(用户改的 + 远端旧版),表现为"改完又变回去/出现两条"。 + const dirtyKeyed: Map = new Map(); + // ⭐ 本地已标记删除、还没推给服务器的 uid 集合。 + // 按 uid 而非 key:删重复系列时本地只留主事件标 deleted, + // 但远端还有一串 RECURRENCE-ID 实例行,key 各不相同,只有 uid 能整族挡住。 + const dirtyDeletedUids: Set = new Set(); 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; diff --git a/entry/src/main/ets/common/SyncEngine.ets b/entry/src/main/ets/common/SyncEngine.ets index 885bff4..52268f0 100644 --- a/entry/src/main/ets/common/SyncEngine.ets +++ b/entry/src/main/ets/common/SyncEngine.ets @@ -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}`);