Files
FamilyBook_harmonyos/entry/src/main/ets/data/FbCalInbound.ets
T
laoyang 86de59616c 端上:#47~#55 打包 —— 行程双轨、动态类型、表单全局规范 (#53)
#48 计划/实际双轨:nodeAction(start/done/cancel),完成即转实际行程,
    实际行程不再显示状态;新增 actualStartedAt/actualEndedAt
#49 类型/出行方式/车辆 chip 改内部读状态(@Builder 值传参不刷新)
#50 类型点击立即变色、出行方式平铺、参与人显示已选头像
#51 节点类型动态加载(原硬编码5种)、地址 onPick 拿详细地址与坐标
#52 标签改主题色 Bold、必填红无星号、编辑行程标题同步
#53 表单两排版式(标签独占一行解决左右对不齐);电影弹层 layoutWeight 修滚动
#54 地址联想框加时间窗抑制(600ms 内不搜)、候选限高 180

含附件回填诊断日志(DIAG-ATTACH/FILL),待真机确认后移除
2026-11-05 12:41:04 +08:00

421 lines
16 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.
/**
* 岁月家 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;
}