Files
SyncCalendar/entry/src/main/ets/common/MirrorInbound.ets
T
laoyang 24d8117ce7 v51c 卡片后台同步:修复"每轮全量重拉",单账号 236 秒 → 10 秒
真机实测根因:服务端 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 兜底语义正确、非碰巧。
2026-10-15 21:39:54 +08:00

347 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 '';
}
}
}