/** * 岁月家 FamilyBook · 系统日历镜像(入站回程) * * 🔴 2026-11-03 新增,2026-11-04 改为**按日历本多账户**。 * 照 SyncCalendar 的 `MirrorInbound`。 * * ## 这是「系统日历 ↔ 岁月家 App ↔ 服务器」闭环的**回程** * * 出站(服务器 → 系统日历)在 `FbCalMirror`,本文件负责反向: * 用户在系统日历里改了 / 新建了 / 删了,都要回写到服务器。 * * ## 多账户带来的结构变化(2026-11-04) * * 第一版只有一个账户(`岁月家`),所以只需对账一次。 * 现在每个日历本一个账户(`岁月家·工作`、`岁月家·家庭`…), * ⇒ **必须逐个账户对账**,且每个账户的条目只能回写到它对应的那个本。 * * for (每个镜像账户) { * bucket = 从账户名解析出的 book * bookId = 从 bucket 解析出的数字 * 对该账户内的条目做三方比对,回写时指定 calendar_id = bookId * } * * ⚠️ 跨本错写是这里最容易犯的错:把"工作"账户里的一条写进"家庭"本。 * 所以 `pushCreate` / `pushUpdate` 都必须带 `calendar_id = bookId`。 * * ## 防回灌(唯一的核心难题,与单账户时相同) * * 系统日历里既有"我们写进去的",也有"用户改的/新建的/删的"。 * 靠 `mirror_snapshot`(上次写入值的内容指纹)三方比对: * * | 系统日历当前状态 | 判定 | 动作 | * |-----------------------------------|------------------|---------------------------| * | 有 identifier,指纹 == 快照 | 我们自己写的 | 跳过 | * | 有 identifier,指纹 != 快照 | 用户改了 | 写回服务器 | * | 有 identifier,但**没有快照** | 认作是我们写的 | **补快照后跳过**(不误判) | * | 快照有,系统里没了 | 用户删了 | 服务器删除 | * | identifier 不是我们的格式 | 用户在系统日历新建 | 新建并推到服务器 | * * ⚠️ 「没有快照就补快照后跳过」这条极关键:首次启用时快照是空的, * 若按"无快照=用户改了"处理,会把全部日程标脏、全量重推服务器。 * * ⚠️ 必须有网络才能回写(SyncCalendar 是离线排队,这里直接推)。 * 离线时本轮跳过,下次再对账 —— 不会丢改动,因为快照没更新、 * 下一轮还会判定成"用户改了"。 */ import { common } from '@kit.AbilityKit'; import { calendarManager } from '@kit.CalendarKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { FbApiResult } from './FbHttp'; import { FbCalEventInput, FbCalRepo } from './FbCalRepo'; import { FbCalMirror, FbMirrorSnap } from './FbCalMirror'; import { FbCalStore } from './FbCalStore'; import { FB_TOKEN_STORE } from './FbTokenStore'; import { FbDiag } from '../common/FbDiag'; /** 一次入站对账的统计 */ export class FbInboundResult { created: number = 0; updated: number = 0; deleted: number = 0; skipped: number = 0; failed: number = 0; /** 对账了几个账户 */ books: number = 0; } export class FbCalInbound { /** 账户 name 前缀,必须与 `FbCalMirror` 完全一致 */ private static readonly ACCOUNT_PREFIX: string = 'familybook.mirror.book'; /** * 入站对账主入口:**逐个镜像账户**对账。 * * ⚠️ 必须在出站镜像 `FbCalMirror.syncNow()` **之前**调用 —— * 它产生的服务器改动要赶上本轮,否则下一轮出站会用旧数据把用户改动覆盖掉。 */ static async reconcile(ctx: common.Context): Promise { const res: FbInboundResult = new FbInboundResult(); if (!await FbCalMirror.hasPermission()) { return res; } const token: string = FB_TOKEN_STORE.get(); if (token === '') { FbDiag.log('INBOUND', '未登录,跳过入站对账'); return res; } try { const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(ctx); // 收集所有镜像账户 const cals: calendarManager.Calendar[] = await cm.getAllCalendars(); const mirrors: calendarManager.Calendar[] = []; for (const c of cals) { if (c.getAccount().name.startsWith(FbCalInbound.ACCOUNT_PREFIX)) { mirrors.push(c); } } if (mirrors.length === 0) { return res; // 还没镜像过,无从对账 } // 快照一次读全(按 identifier 索引,天然区分桶) const snaps: Map = await FbCalMirror.snapLoad(ctx); // 本地条目索引(uid → 服务器 id + 所属本),一次建好所有账户共用 const localIndex: Map = await FbCalInbound.localIndex(); for (const cal of mirrors) { const accName: string = cal.getAccount().name; const bookIdStr: string = accName.substring(FbCalInbound.ACCOUNT_PREFIX.length); const bookId: number = parseInt(bookIdStr, 10); if (isNaN(bookId) || bookId <= 0) { FbDiag.log('INBOUND', '账户名解析不出 bookId,跳过: ' + accName); continue; } const bucket: string = FbCalMirror.bucketOf(bookId); try { await FbCalInbound.reconcileBook(ctx, cal, bucket, bookId, snaps, localIndex, token, res); res.books++; } catch (err) { const e = err as BusinessError; res.failed++; FbDiag.log('INBOUND', '对账失败 bookId=' + bookId + ': ' + e.code + ' ' + e.message); } } if (res.created + res.updated + res.deleted > 0) { FbDiag.log('INBOUND', '对账完成:本=' + res.books + ' 新建=' + res.created + ' 修改=' + res.updated + ' 删除=' + res.deleted + ' 跳过=' + res.skipped + ' 失败=' + res.failed); } } catch (err) { const e = err as BusinessError; FbDiag.log('INBOUND', '对账异常: ' + e.message); } return res; } /* ==================== 单个日历本 ==================== */ private static async reconcileBook(ctx: common.Context, cal: calendarManager.Calendar, bucket: string, bookId: number, snaps: Map, localIndex: Map, token: string, res: FbInboundResult): Promise { const prefix: string = bucket + '|'; // 系统日历现有条目 let events: calendarManager.Event[] = []; try { events = await cal.getEvents(); } catch (err) { const e = err as BusinessError; FbDiag.log('INBOUND', 'getEvents() 失败 bookId=' + bookId + ': ' + e.message); return; } // 分成两类:带本桶 identifier 的(可判定改/删)vs 不是我们的(用户新建) const mine: Map = new Map(); const others: calendarManager.Event[] = []; for (const ev of events) { const id: string = ev.identifier ?? ''; if (id.startsWith(prefix)) { mine.set(id, ev); } else { others.push(ev); } } // ---------- ① 修改 ---------- for (const id of Array.from(mine.keys())) { const ev: calendarManager.Event | undefined = mine.get(id); if (ev === undefined) { continue; } const fp: string = FbCalMirror.fingerprintOf(ev); const snap: FbMirrorSnap | undefined = snaps.get(id); if (snap === undefined) { /* ⚠️ 没有快照 ⇒ 认作是我们自己写的,补一份后跳过。 绝不误判成"用户改了" —— 首次启用时快照是空的, 那样会把全部日程标脏、全量重推服务器。 */ await FbCalInbound.saveSnap(ctx, bucket, id, ev, fp); res.skipped++; continue; } snaps.delete(id); // 还活着,稍后剩下的才是"被删了" if (snap.fingerprint === fp) { res.skipped++; // 没动 continue; } const ref: LocalRef | undefined = localIndex.get(id); if (ref === undefined || ref.id <= 0) { res.skipped++; // 本地已无此条(服务器删了/还没落库)→ 交给出站处理 continue; } /* ⚠️ 跨本防护:万一 identifier 的桶与当前账户不一致(历史脏数据), 宁可跳过也不要把 A 本的事写到 B 本。 */ if (ref.calId !== bookId) { FbDiag.log('INBOUND', '跳过跨本条目 ' + id + '(本地本=' + ref.calId + ' 账户本=' + bookId + ')'); res.skipped++; continue; } const ok: boolean = await FbCalInbound.pushUpdate(ref.id, ev, bookId, token); if (ok) { await FbCalInbound.saveSnap(ctx, bucket, id, ev, fp); res.updated++; } else { res.failed++; } } // ---------- ② 删除:快照里剩下、且属于本桶的 ---------- for (const id of Array.from(snaps.keys())) { const snap: FbMirrorSnap | undefined = snaps.get(id); if (snap === undefined) { continue; } if (snap.bucket !== bucket) { continue; // 别的桶的,留给那本处理 } const ref: LocalRef | undefined = localIndex.get(id); if (ref !== undefined && ref.id > 0 && ref.calId === bookId) { const ok: boolean = await FbCalInbound.pushDelete(ref.id, token); if (ok) { res.deleted++; } else { res.failed++; } } else { res.skipped++; } await FbCalMirror.snapRemove(ctx, id); snaps.delete(id); } // ---------- ③ 新建:identifier 不是本桶格式的(用户在系统日历里手动加的) ---------- for (const ev of others) { if (ev.id === undefined) { continue; } /* ⚠️ 先把 identifier 写回系统日历,让它变成"我们的"条目。 否则下一次出站镜像会把同一条再加一遍 ⇒ 系统日历里出现重复。 */ const ident: string = bucket + '|sc-' + ev.id.toString() + '-' + ev.startTime.toString(); const stamped: calendarManager.Event = ev; stamped.identifier = ident; try { await cal.updateEvent(stamped); } catch (err) { const e = err as BusinessError; FbDiag.log('INBOUND', '无法写回 identifier,跳过新建(避免重复): ' + e.message); res.skipped++; continue; } const newId: number = await FbCalInbound.pushCreate(ev, bookId, token); if (newId > 0) { await FbCalInbound.saveSnap(ctx, bucket, ident, ev, FbCalMirror.fingerprintOf(ev)); res.created++; } else { res.failed++; } } } /* ==================== 推服务器 ==================== */ /** * 系统日历 Event → 接口输入。 * * ⚠️ `calendar_id` 必须带上 —— 否则服务端会落到默认本, * 用户在"工作"账户里新建的事会跑到别处去。 */ private static toInput(ev: calendarManager.Event, bookId: number): FbCalEventInput { const input: FbCalEventInput = { calendar_id: bookId, title: ev.title !== undefined && ev.title !== '' ? ev.title : '(无标题)', starts_at: FbCalInbound.fmtDateTime(ev.startTime), ends_at: FbCalInbound.fmtDateTime(ev.endTime), all_day: ev.isAllDay === true, location: ev.location?.location !== undefined ? (ev.location.location as string) : '', note: ev.description !== undefined ? ev.description : '' }; return input; } private static async pushUpdate(id: number, ev: calendarManager.Event, bookId: number, token: string): Promise { try { const r: FbApiResult = await FbCalRepo.inst().update(id, FbCalInbound.toInput(ev, bookId), token); if (!r.ok) { FbDiag.log('INBOUND', '更新服务器失败 id=' + id + ': ' + r.error); return false; } return true; } catch (err) { const e = err as BusinessError; FbDiag.log('INBOUND', '更新服务器异常 id=' + id + ': ' + e.message); return false; } } private static async pushDelete(id: number, token: string): Promise { try { const r: FbApiResult = await FbCalRepo.inst().remove(id, token); if (!r.ok) { FbDiag.log('INBOUND', '删除服务器失败 id=' + id + ': ' + r.error); return false; } return true; } catch (err) { const e = err as BusinessError; FbDiag.log('INBOUND', '删除服务器异常 id=' + id + ': ' + e.message); return false; } } /** 新建 → 返回新条目 id(0 = 失败) */ private static async pushCreate(ev: calendarManager.Event, bookId: number, token: string): Promise { try { const r: FbApiResult = await FbCalRepo.inst().create( FbCalInbound.toInput(ev, bookId), token); if (!r.ok) { FbDiag.log('INBOUND', '新建服务器失败: ' + r.error); return 0; } if (r.data === '') { return 0; } const obj: Record = JSON.parse(r.data) as Record; const idVal: Object | undefined = obj['id']; if (typeof idVal === 'number') { return idVal as number; } return 0; } catch (err) { const e = err as BusinessError; FbDiag.log('INBOUND', '新建服务器异常: ' + e.message); return 0; } } /* ==================== 本地索引 ==================== */ /** * 建 `identifier → 本地条目` 的索引。 * * identifier 的算法必须与出站 `FbCalMirror.identifierOf()` **完全一致**, * 否则两边永远对不上号(表现:用户在系统日历改了,服务器没变)。 * ⇒ 这里直接调 `FbCalMirror.identifierOf()`,不自己算。 */ private static async localIndex(): Promise> { const out: Map = new Map(); try { const now: Date = new Date(); const from: Date = new Date(now.getFullYear(), now.getMonth() - 13, 1); const to: Date = new Date(now.getFullYear(), now.getMonth() + 26, 1); const p = (n: number): string => n < 10 ? '0' + n : '' + n; const fromIso: string = from.getFullYear() + '-' + p(from.getMonth() + 1) + '-01'; const toIso: string = to.getFullYear() + '-' + p(to.getMonth() + 1) + '-01'; const rows = await FbCalStore.inst().between(fromIso, toIso); for (const e of rows) { if (e.id <= 0 || e.calId <= 0) { continue; // 衍生条目推不上去 } const bucket: string = FbCalMirror.bucketOf(e.calId); const ident: string = FbCalMirror.identifierOf(bucket, e); const ref: LocalRef = new LocalRef(); ref.id = e.id; ref.calId = e.calId; out.set(ident, ref); } } catch (err) { const e = err as BusinessError; FbDiag.log('INBOUND', '建本地索引失败: ' + e.message); } return out; } /* ==================== 辅助 ==================== */ private static async saveSnap(ctx: common.Context, bucket: string, identifier: string, ev: calendarManager.Event, fp: string): Promise { const s: FbMirrorSnap = new FbMirrorSnap(); s.identifier = identifier; s.bucket = bucket; s.uid = FbCalMirror.uidOf(identifier); s.sysId = ev.id ?? -1; s.fingerprint = fp; await FbCalMirror.snapPut(ctx, s); } /** 时间戳 → `YYYY-MM-DD HH:MM:SS`(接口要这个格式) */ private static fmtDateTime(ts: number): string { const d: Date = new Date(ts); const p = (n: number): string => n < 10 ? '0' + n : '' + n; return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()); } } /** 本地条目引用(回写时要知道"改哪条、在哪个本") */ class LocalRef { /** 服务器条目 id */ id: number = 0; /** 所属日历本 id —— 用于跨本防护 */ calId: number = 0; }