真机实测根因:服务端 CalDav.php:253 用 preg_replace('/[^A-Za-z0-9._-]/','_',$uid)
派生资源文件名(UID movie-188@familybook → 资源 movie-188_familybook.ics),
而客户端 SyncEngine.syncAccountInner 拿 REPORT 给的**资源名当 uid** 去本地行里查
⇒ byUid 永远查不到 ⇒ 每轮判"变了" ⇒ 全量 GET。ETag 明明一样,比较压根执行不到。
改动:
· SyncEngine —— 加 byEtag 兜底(uid 查不到就用内容哈希认领本地行)+ 复用判定计数日志
(uid 命中 / etag 兜底命中 / 需补拉);抽 groupPush() 去重。
· EventDb.applyRemote —— existing.find() 的 O(n·m) → keyed.get() 的 O(n)。
· BackgroundSyncRunner —— runOnce 每阶段计时(+Xms)+ pid;预算可由 preferences
card_budget_ms 覆盖(诊断开关,默认走内置 9 秒)。
· AppSettings —— getCardBudgetMs()。
· EntryFormAbility —— onUpdateForm 日志带 pid。
· MirrorInbound —— 顺带修一个已死的功能:getEvents(undefined, 字段) 在 7.0.0.109 上
必抛 401 type error!,导致**入站对账 12/12 全失败**;改成"先无参、失败再带字段"
(照抄 SystemCalendarMirror.queryEvents 的绕法)。
真机验证(DEL-AL20,卡片 onUpdateForm 触发,卡片进程 pid=27411):
[21:33:06] 卡片驱动同步:开始(可用窗口 9 秒)
[21:33:09] 「观影」复用判定:uid 命中 0、etag 兜底命中 193、需补拉 0(共 193)
[21:33:09] 「观影」落库完成:不变193,耗时 1 秒 ← 修复前 124 秒
[21:33:17] 同步账号完成:拉取 2182 条,耗时 10 秒 ← 修复前 215~236 秒
[21:33:22] 后台同步[cardTick] 完成:1/1 个账号,总耗时 16 秒
12 个日历本全部跑完 + 出站镜像,9 秒窗口不再"连第一个本都过不去"。
复算(真库快照 + 真服务器 PROPFIND,_tooling/_sc_etag_sim.py):
旧逻辑补拉 1384 个 → 新逻辑 80 个(降 94.2%);观影 193→0、旅行 23→0、工作 362→5。
另证服务端 ETag == md5(正文)、三次 PROPFIND 逐字节一致 ⇒ etag 兜底语义正确、非碰巧。
347 lines
14 KiB
Plaintext
347 lines
14 KiB
Plaintext
// entry/src/main/ets/common/MirrorInbound.ets
|
||
// 入站对账:把"用户在系统日历里做的改动"读回来,写回本地库并置 dirty,随下一次同步推送到 CalDAV。
|
||
//
|
||
// 这是「系统日历 ↔ 同步日历 App ↔ CalDAV 服务器」闭环的**回程**。
|
||
// 出站(CalDAV → 系统日历)在 SystemCalendarMirror,本文件负责反向。
|
||
//
|
||
// ⚠️ 防回灌是这里唯一的核心难题:系统日历里既有"我们写进去的",也有"用户改的/新建的/删的",
|
||
// 靠 `MirrorSnapshot`(上次写入值的内容指纹)区分:
|
||
//
|
||
// | 系统日历当前状态 | 判定 | 动作 |
|
||
// |-----------------------------------|------------------|---------------------------|
|
||
// | 有 identifier,指纹 == 快照 | 我们自己写的 | 跳过 |
|
||
// | 有 identifier,指纹 != 快照 | 用户改了 | 写回本地库 → 置 dirty 推送 |
|
||
// | 有 identifier,但**没有快照** | 认作是我们写的 | **补快照后跳过**(不误判) |
|
||
// | 快照有,系统里没了 | 用户删了 | markDeleted → 推送 DELETE |
|
||
// | identifier 不是我们的格式 | 用户在系统日历新建 | 新建 LocalEvent → 推送 |
|
||
//
|
||
// ⚠️ "没有快照就补快照后跳过"这条非常关键:首次启用回写时快照是空的,
|
||
// 若按"无快照=用户改了"处理,会把全部日程标脏、全量重推服务器。
|
||
|
||
import { common } from '@kit.AbilityKit';
|
||
import { calendarManager } from '@kit.CalendarKit';
|
||
import { BusinessError } from '@kit.BasicServicesKit';
|
||
import { EventDb, LocalEvent } from './EventDb';
|
||
import { LogUtil } from './LogUtil';
|
||
import { AppSettings } from './AppSettings';
|
||
import { MirrorSnapshot, MirrorSnapshotRow } from './MirrorSnapshot';
|
||
import { BookRef, SystemCalendarMirror } from './SystemCalendarMirror';
|
||
|
||
/** 一次入站对账的统计 */
|
||
export class InboundResult {
|
||
created: number = 0; // 系统日历新建 → 加进 CalDAV
|
||
updated: number = 0; // 系统日历修改 → 回写 CalDAV
|
||
deleted: number = 0; // 系统日历删除 → 从 CalDAV 删
|
||
skipped: number = 0;
|
||
failed: number = 0;
|
||
}
|
||
|
||
export class MirrorInbound {
|
||
private static readonly DAY: number = 86400000;
|
||
private static readonly QUERY_FIELDS: (keyof calendarManager.Event)[] = [
|
||
'id', 'type', 'title', 'startTime', 'endTime', 'isAllDay',
|
||
'description', 'location', 'identifier', 'reminderTime', 'recurrenceRule'
|
||
];
|
||
|
||
/**
|
||
* 入站对账主入口。
|
||
* ⚠️ 必须在 `SyncEngine.syncAccount()` **之前**调用 —— 它产生的 dirty 行要借本次同步一起推送。
|
||
*/
|
||
static async reconcile(context: common.Context): Promise<InboundResult> {
|
||
const res: InboundResult = new InboundResult();
|
||
try {
|
||
const on: boolean = await AppSettings.getMirrorInbound(context);
|
||
if (!on) {
|
||
return res;
|
||
}
|
||
if (!await SystemCalendarMirror.hasPermission()) {
|
||
return res;
|
||
}
|
||
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context);
|
||
// ⭐ 与出站镜像用**同一套**稳定引用(bookId 由服务器 href 派生),否则两边对不上号
|
||
const refs: BookRef[] = await SystemCalendarMirror.selectedBookRefs(context);
|
||
for (const ref of refs) {
|
||
try {
|
||
await MirrorInbound.reconcileBook(context, cm, ref, res);
|
||
} catch (err) {
|
||
const e = err as BusinessError;
|
||
res.failed++;
|
||
LogUtil.write(`入站对账失败 bookId=${ref.bookId}: ${e.code ?? ''} ${e.message}`);
|
||
}
|
||
}
|
||
if (res.created + res.updated + res.deleted > 0) {
|
||
LogUtil.write(
|
||
`入站对账:新建=${res.created} 修改=${res.updated} 删除=${res.deleted} 跳过=${res.skipped}`);
|
||
}
|
||
} catch (err) {
|
||
const e = err as BusinessError;
|
||
LogUtil.write(`入站对账异常: ${e.message}`);
|
||
}
|
||
return res;
|
||
}
|
||
|
||
/* ==================== 单个日历本 ==================== */
|
||
|
||
/**
|
||
* 取回该账户下已有日程。
|
||
*
|
||
* 🔴 2026-10-15 实测(DEL-AL20 / 7.0.0.109):`getEvents(undefined, 字段数组)` 在这个
|
||
* 系统版本上**必抛** `401 type error!`。而本文件原先正是这么调的 ⇒ "入站对账"对
|
||
* **每一个**日历本都失败(真机日志 12/12 全是 `401 type error!`),
|
||
* 整个"把系统日历里的改动回写 CalDAV"等于死的(还白费 12 次调用)。
|
||
* 出站镜像(SystemCalendarMirror.queryEvents)早就踩过同一个坑,改成"先无参、失败再带字段"。
|
||
* 这里沿用同一策略:无参调用返回的 Event 里 identifier 同样有值。
|
||
*/
|
||
private static async queryEvents(cal: calendarManager.Calendar): Promise<calendarManager.Event[]> {
|
||
try {
|
||
return await cal.getEvents();
|
||
} catch (err) {
|
||
LogUtil.write(`入站对账 getEvents() 失败,退回带字段:${(err as BusinessError).message}`);
|
||
return await cal.getEvents(undefined, MirrorInbound.QUERY_FIELDS);
|
||
}
|
||
}
|
||
|
||
private static async reconcileBook(
|
||
context: common.Context,
|
||
cm: calendarManager.CalendarManager,
|
||
ref: BookRef,
|
||
res: InboundResult
|
||
): Promise<void> {
|
||
const calKey: string = ref.calKey; // 仅用于查本地库
|
||
const bucket: string = ref.bookId; // ⭐ 系统日历侧的一切标识都用 bookId
|
||
const accountName: string = ref.sysAccountName;
|
||
let cal: calendarManager.Calendar | undefined = undefined;
|
||
const calendars: calendarManager.Calendar[] = await cm.getAllCalendars();
|
||
for (const c of calendars) {
|
||
if (c.getAccount().name === accountName) {
|
||
cal = c;
|
||
break;
|
||
}
|
||
}
|
||
if (cal === undefined) {
|
||
return; // 还没镜像过,无从对账
|
||
}
|
||
const href: string = ref.href;
|
||
if (href === '') {
|
||
LogUtil.write(`入站对账跳过 bookId=${bucket}:找不到对应的日历本集合 URL`);
|
||
return;
|
||
}
|
||
|
||
const snaps: Map<string, MirrorSnapshotRow> = await MirrorSnapshot.loadMap(context, bucket);
|
||
const events: calendarManager.Event[] = await MirrorInbound.queryEvents(cal);
|
||
|
||
// 分成两类:带我们 identifier 的(可判定改/删) vs 不是我们的(用户新建)
|
||
const mine: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
|
||
const others: calendarManager.Event[] = [];
|
||
for (const ev of events) {
|
||
const id: string = ev.identifier ?? '';
|
||
if (id !== '' && id.startsWith(`${bucket}|`)) {
|
||
mine.set(id, ev);
|
||
} else {
|
||
others.push(ev);
|
||
}
|
||
}
|
||
|
||
// 本地库现有事件:uid -> LocalEvent
|
||
const locals: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
|
||
const byUid: Map<string, LocalEvent> = new Map<string, LocalEvent>();
|
||
for (const e of locals) {
|
||
if (e.uid !== '') {
|
||
byUid.set(e.uid, e);
|
||
}
|
||
}
|
||
|
||
// ① 修改
|
||
for (const id of mine.keys()) {
|
||
const ev: calendarManager.Event | undefined = mine.get(id);
|
||
if (ev === undefined) {
|
||
continue;
|
||
}
|
||
const fp: string = MirrorSnapshot.fingerprintOf(ev);
|
||
const snap: MirrorSnapshotRow | undefined = snaps.get(id);
|
||
if (snap === undefined) {
|
||
// ⚠️ 没有快照:认作是我们自己写的,补一份后跳过 —— 绝不误判成"用户改了"
|
||
await MirrorSnapshot.put(context, MirrorInbound.rowOf(bucket, id, ev, fp));
|
||
res.skipped++;
|
||
continue;
|
||
}
|
||
snaps.delete(id); // 还活着,稍后剩下的才是"被删了"
|
||
if (snap.fingerprint === fp) {
|
||
res.skipped++; // 没动
|
||
continue;
|
||
}
|
||
// ⚠️ 重复日程的**某一次发生**(identifier 形如 `<bookId>|<uid>@<发生时间戳>`):
|
||
// 它是我们从一条重复日程展开出来的,不是独立事件 —— 改它没法映射回服务器上的
|
||
// 那一条 VEVENT,直接跳过,避免把服务器数据搞乱。
|
||
if (MirrorInbound.isOccurrence(id)) {
|
||
res.skipped++;
|
||
await MirrorSnapshot.put(context, MirrorInbound.rowOf(bucket, id, ev, fp));
|
||
continue;
|
||
}
|
||
const uid: string = MirrorSnapshot.uidOf(id);
|
||
const local: LocalEvent | undefined = byUid.get(uid);
|
||
if (local === undefined) {
|
||
res.skipped++; // 本地已无此条(服务器删了/还没落库)→ 交给出站镜像处理
|
||
continue;
|
||
}
|
||
if (local.dirty || local.deleted) {
|
||
res.skipped++; // 本地本来就有待推送的改动 → 不拿系统值覆盖,避免两边打架
|
||
continue;
|
||
}
|
||
MirrorInbound.applyToLocal(local, ev);
|
||
await EventDb.updateLocal(context, local);
|
||
await MirrorSnapshot.put(context, MirrorInbound.rowOf(bucket, id, ev, fp));
|
||
res.updated++;
|
||
LogUtil.write(`入站修改回写:${id}(${local.title})`);
|
||
}
|
||
|
||
// ② 删除:快照里剩下的(系统日历里已经没有了)
|
||
for (const id of snaps.keys()) {
|
||
const snap: MirrorSnapshotRow | undefined = snaps.get(id);
|
||
if (snap === undefined) {
|
||
continue;
|
||
}
|
||
// 同上:展开出来的"某一次发生"消失(多半是滑出镜像窗口),不能当成用户删除
|
||
if (MirrorInbound.isOccurrence(id)) {
|
||
res.skipped++;
|
||
await MirrorSnapshot.remove(context, id);
|
||
continue;
|
||
}
|
||
const local: LocalEvent | undefined = byUid.get(snap.uid);
|
||
if (local !== undefined && !local.deleted) {
|
||
await EventDb.markDeleted(context, local.id);
|
||
res.deleted++;
|
||
LogUtil.write(`入站删除回写:${id}(${local.title})`);
|
||
} else {
|
||
res.skipped++;
|
||
}
|
||
await MirrorSnapshot.remove(context, id);
|
||
}
|
||
|
||
// ③ 新建:系统日历里 identifier 不是我们格式的条目(用户在系统日历里手动加的)
|
||
for (const ev of others) {
|
||
if (ev.id === undefined) {
|
||
continue;
|
||
}
|
||
const uid: string = `sc-${bucket}-${ev.id}`;
|
||
if (byUid.has(uid)) {
|
||
continue;
|
||
}
|
||
// ⚠️ 先把 identifier 写回系统日历,让它变成"我们的"条目。
|
||
// 否则下一次出站镜像会把同一条再加一遍 → 系统日历里出现重复。
|
||
const identifier: string = `${bucket}|${uid}`;
|
||
const stamped: calendarManager.Event = ev;
|
||
stamped.identifier = identifier;
|
||
try {
|
||
await cal.updateEvent(stamped);
|
||
} catch (err) {
|
||
const e = err as BusinessError;
|
||
res.skipped++;
|
||
LogUtil.write(`入站新建跳过:无法写回 identifier(${e.message}),避免重复`);
|
||
continue;
|
||
}
|
||
const e: LocalEvent = new LocalEvent();
|
||
e.uid = uid;
|
||
e.calKey = calKey;
|
||
e.href = href;
|
||
e.remotePath = encodeURIComponent(uid) + '.ics';
|
||
e.kind = 'event';
|
||
MirrorInbound.applyToLocal(e, ev);
|
||
await EventDb.insertLocal(context, e);
|
||
await MirrorSnapshot.put(context, MirrorInbound.rowOf(
|
||
bucket, identifier, ev, MirrorSnapshot.fingerprintOf(ev)));
|
||
res.created++;
|
||
LogUtil.write(`入站新建回写:${identifier}(${e.title})`);
|
||
}
|
||
}
|
||
|
||
/* ==================== 辅助 ==================== */
|
||
|
||
/**
|
||
* 是不是"重复系列里的某一次发生"?identifier 形如 `<bookId>|<uid>@<发生时间戳>`。
|
||
*
|
||
* ⚠️ 不能用 `includes('@')` 判断:**uid 本身就常含 @**(如 `20260906T163329-d13e0cb6@172.17.0.1`),
|
||
* 会误判。只有"最后一段是纯数字"才是我们加的发生时间戳。
|
||
*/
|
||
private static isOccurrence(id: string): boolean {
|
||
const pos: number = id.lastIndexOf('@');
|
||
if (pos < 0) {
|
||
return false;
|
||
}
|
||
const tail: string = id.substring(pos + 1);
|
||
if (tail === '') {
|
||
return false;
|
||
}
|
||
for (let i: number = 0; i < tail.length; i++) {
|
||
const c: string = tail[i];
|
||
if (c < '0' || c > '9') {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private static rowOf(
|
||
bucket: string,
|
||
identifier: string,
|
||
ev: calendarManager.Event,
|
||
fp: string
|
||
): MirrorSnapshotRow {
|
||
const row: MirrorSnapshotRow = new MirrorSnapshotRow();
|
||
row.identifier = identifier;
|
||
row.calKey = bucket;
|
||
row.uid = MirrorSnapshot.uidOf(identifier);
|
||
row.sysId = ev.id ?? -1;
|
||
row.fingerprint = fp;
|
||
return row;
|
||
}
|
||
|
||
/** 系统日历 Event → 本工程 LocalEvent(只覆盖可映射字段) */
|
||
private static applyToLocal(e: LocalEvent, ev: calendarManager.Event): void {
|
||
e.title = ev.title ?? '';
|
||
e.description = ev.description ?? '';
|
||
e.location = ev.location?.location ?? '';
|
||
e.startTime = ev.startTime;
|
||
e.isAllDay = ev.isAllDay === true;
|
||
// 系统全天 endTime 可能是"次日 00:00",也可能是"当日 23:59:59.999"
|
||
// → 统一成本工程约定:"排他结束日前一毫秒"
|
||
e.endTime = e.isAllDay
|
||
? MirrorInbound.dayStart(ev.endTime - 1) + MirrorInbound.DAY - 1
|
||
: ev.endTime;
|
||
e.rrule = MirrorInbound.rruleOf(ev);
|
||
e.recurring = e.rrule !== '';
|
||
if (ev.reminderTime !== undefined && ev.reminderTime.length > 0) {
|
||
e.reminders = [...ev.reminderTime];
|
||
e.reminder = ev.reminderTime[0];
|
||
}
|
||
}
|
||
|
||
private static dayStart(ts: number): number {
|
||
const d: Date = new Date(ts);
|
||
d.setHours(0, 0, 0, 0);
|
||
return d.getTime();
|
||
}
|
||
|
||
/** 系统 RecurrenceRule → RRULE 字符串(仅 FREQ/INTERVAL;复杂规则以服务器端为准) */
|
||
private static rruleOf(ev: calendarManager.Event): string {
|
||
try {
|
||
const rr = ev.recurrenceRule;
|
||
if (rr === undefined) {
|
||
return '';
|
||
}
|
||
const freqMap: string[] = ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY'];
|
||
const fi: number = rr.recurrenceFrequency as number;
|
||
if (Number.isNaN(fi) || fi < 0 || fi >= freqMap.length) {
|
||
return '';
|
||
}
|
||
let rrule: string = `FREQ=${freqMap[fi]}`;
|
||
if (rr.interval !== undefined && rr.interval > 1) {
|
||
rrule += `;INTERVAL=${rr.interval}`;
|
||
}
|
||
return rrule;
|
||
} catch (err) {
|
||
return '';
|
||
}
|
||
}
|
||
}
|