#48 计划/实际双轨:nodeAction(start/done/cancel),完成即转实际行程, 实际行程不再显示状态;新增 actualStartedAt/actualEndedAt #49 类型/出行方式/车辆 chip 改内部读状态(@Builder 值传参不刷新) #50 类型点击立即变色、出行方式平铺、参与人显示已选头像 #51 节点类型动态加载(原硬编码5种)、地址 onPick 拿详细地址与坐标 #52 标签改主题色 Bold、必填红无星号、编辑行程标题同步 #53 表单两排版式(标签独占一行解决左右对不齐);电影弹层 layoutWeight 修滚动 #54 地址联想框加时间窗抑制(600ms 内不搜)、候选限高 180 含附件回填诊断日志(DIAG-ATTACH/FILL),待真机确认后移除
421 lines
16 KiB
Plaintext
421 lines
16 KiB
Plaintext
/**
|
||
* 岁月家 FamilyBook · 系统日历镜像(入站回程)
|
||
*
|
||
* 🔴 2026-11-03 新增,2026-11-04 改为**按日历本多账户**。
|
||
* 照 SyncCalendar 的 `MirrorInbound`。
|
||
*
|
||
* ## 这是「系统日历 ↔ 岁月家 App ↔ 服务器」闭环的**回程**
|
||
*
|
||
* 出站(服务器 → 系统日历)在 `FbCalMirror`,本文件负责反向:
|
||
* 用户在系统日历里改了 / 新建了 / 删了,都要回写到服务器。
|
||
*
|
||
* ## 多账户带来的结构变化(2026-11-04)
|
||
*
|
||
* 第一版只有一个账户(`岁月家`),所以只需对账一次。
|
||
* 现在每个日历本一个账户(`岁月家·工作`、`岁月家·家庭`…),
|
||
* ⇒ **必须逐个账户对账**,且每个账户的条目只能回写到它对应的那个本。
|
||
*
|
||
* for (每个镜像账户) {
|
||
* bucket = 从账户名解析出的 book<id>
|
||
* 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<FbInboundResult> {
|
||
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<string, FbMirrorSnap> = await FbCalMirror.snapLoad(ctx);
|
||
|
||
// 本地条目索引(uid → 服务器 id + 所属本),一次建好所有账户共用
|
||
const localIndex: Map<string, LocalRef> = 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<string, FbMirrorSnap>,
|
||
localIndex: Map<string, LocalRef>, token: string, res: FbInboundResult): Promise<void> {
|
||
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<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
|
||
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<boolean> {
|
||
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<boolean> {
|
||
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<number> {
|
||
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<string, Object> = JSON.parse(r.data) as Record<string, Object>;
|
||
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<Map<string, LocalRef>> {
|
||
const out: Map<string, LocalRef> = new Map<string, LocalRef>();
|
||
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<void> {
|
||
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;
|
||
} |