/** * 家书 FamilyBook · 全量 / 增量同步(鸿蒙端) * * 🔴 为什么需要它(用户 2026-10-12:"把同步做成全局的,接进来一个模块, * 就自动同步一个模块"): * 之前每个页面**自己同步自己**,结果是"用户必须把每个页面都点一遍才拿到全量数据", * 而且设置页那个「保存并同步」只同步了观影 —— 一个入口只干一部分活,用户没法理解。 * ⇒ 加这个"全量同步":**一处触发、全部模块都更新**。 * * 🔴 v59(用户 2026-10-17)把它升级成**首次全量 / 之后增量**: * 「登录后立即进入主页面,变同步变更新应用中的数据,后续增量同步也一样」。 * 做法见下面 `plan()`: * ① 先打 `GET /api/v1/changes`,服务端给每个模块一个**变更指纹** * (行数 + 最大 id + 最大 updated_at,见服务端 `changes()` 的说明); * ② 指纹与本地 `module_sync` 里记的一样 ⇒ **这个模块根本没变,不拉**; * ③ 不一样 / 本地没记录 ⇒ 拉。 * ⚠️ 为什么端上要"没记录就拉":老用户从 v58 之前的版本升上来时 `module_sync` 是空的, * 第一条记录必须靠全量建立 —— 否则会"一个模块都不同步"。 * * 🔴 **陈旧上限(`FORCE_FULL_AFTER_MS`)不是补丁,是这套方案的一部分**: * 服务端有一半的表没有 `updated_at`(`movie_details` 等,见服务端注释), * "改一张没有 updated_at 的表里已有的一行"**指纹察觉不到**。 * 所以"上次真正拉取"超过 24 小时就无条件全量一次。 * ⇒ 最坏情况:改了某个字段,最迟 24 小时后端上能看到。 * 对"家里看日程/看电影"这类数据,这个延迟是可以接受的; * 而换来的是"日常打开 App 只拉变了的那一两个模块"。 * * ⚠️ 它与"页面各自同步"**不冲突**,两者都要有: * · 页面各自同步:保证"刚进这一页就有最新数据"; * · 全量同步:保证"不用点遍每一页"。 */ import { hilog } from '@kit.PerformanceAnalysisKit'; import { FbMovieRepo, FbSyncResult } from './FbMovieRepo'; import { FbPersonRepo, FbRosterSyncResult } from './FbPersonRepo'; import { FbDb, FbRow } from './FbDb'; import { FbModuleLoad, FbModuleRepo } from './FbModuleRepo'; import { FB_MODULE_PATH } from './FbModuleLoad'; import { FB_TOKEN_STORE } from './FbTokenStore'; import { FbApiOptions, FbApiResult, fbApi } from './FbHttp'; import { FB_ATTACH } from './FbAttachQueue'; import { FbSyncState, fbSyncState } from '../common/FbSyncStore'; const DOMAIN: number = 0x0000; const TAG: string = 'FbSyncAll'; /** * 陈旧上限:距上次**真正拉取**超过这么久,就无条件全量一次。 * 理由见文件头"陈旧上限不是补丁"那段。 */ const FORCE_FULL_AFTER_MS: number = 24 * 3600 * 1000; /** 全量同步结果 */ export interface FbSyncAllResult { ok: boolean; /** 成功同步的模块数(**含**因"没变化"而跳过的) */ done: number; /** 总模块数 */ total: number; /** 失败模块名(用户看得到的人话) */ failed: string[]; /** 当前进度文案(同步中实时更新用) */ note: string; /** 本次因"服务端说没变"而跳过的模块数(增量生效的证据) */ skipped: number; } /** * 进度回调(可选)。 * * 🔴 为什么还要保留这个参数(v59 已经有 `FbSyncState` 了): * 登录页是"用户正盯着屏幕等首次同步"的场景,它需要一个**从函数里推出来**的回调; * 而 `FbSyncState` 是给顶栏/面板这种"订阅制"的地方用的。两者不冲突。 */ export type FbSyncStepCb = (label: string, done: number, total: number) => void; /** 模块的中文名(进度提示用) */ const MODULE_CN: Record = { 'movies': '观影', 'roster': '名册', 'calendar': '日程', 'tasks': '任务', 'days': '纪念日', 'trips': '旅程', 'ledger': '账本', 'resources': '资源', 'books': '藏书', 'notes': '笔记', 'notifications': '通知', 'home': '首页', }; /** * 走通用缓存的模块(观影 / 名册有实体表,单独走)。 * * ⚠️ v74:`kin`(家谱)已从端上删除,这里一并移除 —— 否则每次全量同步 * 都会去拉一个端上已经没有任何页面消费的接口。 */ const CACHE_MODULES: string[] = [ 'calendar', 'tasks', 'days', 'trips', 'ledger', 'resources', 'books', 'notes', 'notifications', ]; /** `GET /api/v1/changes` 的响应 */ interface FbChangesData { server_time?: string; /** 模块名 → 指纹(null = 服务端判断不了) */ modules?: Record; all?: string; } interface FbChangesEnvelope { ok?: boolean; error?: string; code?: string; data?: FbChangesData; } /** 本地某个模块已同步到的状态 */ interface FbModMark { fp: string; fullAt: number; } export async function syncAll(onStep?: FbSyncStepCb): Promise { const token: string = FB_TOKEN_STORE.get(); const st: FbSyncState = fbSyncState(); if (token === '') { st.failAll('未登录,无法同步'); const r: FbSyncAllResult = { ok: false, done: 0, total: 1 + 1 + CACHE_MODULES.length + 1, failed: [], note: '未登录,无法同步', skipped: 0, }; return r; } // 总步数**由模块表算出来**(观影 + 名册 + 缓存模块 + 首页;跳过也占一步,百分比才准)。 // 🔴 别写死数字:v59 加 `kin` 时写死的 12 就会让进度条最后停在 11/12 // —— 写死常量的进度和真实的循环次数是两套账,迟早对不上。 const total: number = 1 + 1 + CACHE_MODULES.length + 1; st.begin(total); let done: number = 0; const failed: string[] = []; /** 跑一个模块并记账 */ const step = async (label: string, work: () => Promise): Promise => { st.step(label, done, total); if (onStep !== undefined) { onStep(label, done, total); } try { const ok: boolean = await work(); if (ok) { done += 1; } else { failed.push(label); done += 1; } } catch (err) { hilog.warn(DOMAIN, TAG, '同步 %{public}s 失败:%{public}s', label, JSON.stringify(err)); failed.push(label); done += 1; } }; /** 记一步"服务端说没变,跳过" */ const skip = (label: string): void => { st.step(label + '(没变化)', done, total); if (onStep !== undefined) { onStep(label, done, total); } st.skipOne(); done += 1; }; // ---- ① 问一句"哪些模块变了" ---- const marks: Map = await loadMarks(); const fpResult: FbChangesData | null = await fetchChanges(token); if (fpResult === null) { /* `/changes` 没拿到:两种可能,处理方式**必须不同**。 ① 真断网 → 立刻收工。不能退回全量:12 个模块各超时 15 秒 = 3 分钟白等, 用户会以为 App 卡死(这正是之前"同步很久"的观感来源)。 ② 服务端是老版本(没这个接口,回 404)→ 退回"全部拉",功能不能因此断掉。 判据:再打一次 `/ping`(它不需要令牌、开销极小)。 连 `/ping` 都到不了 ⇒ 断网;`/ping` 有 HTTP 状态码 ⇒ 服务端在,只是没这接口。 */ if (!(await pingReachable())) { const msg: string = '连不上服务器,等会儿自动重试'; st.failAll(msg); const r: FbSyncAllResult = { ok: false, done: 0, total: total, failed: [], note: msg, skipped: 0, }; return r; } hilog.warn(DOMAIN, TAG, '服务端不支持变更指纹,退回全量同步'); } /** 该查什么:true = 需要拉 */ const plan = (name: string): boolean => { if (fpResult === null) { return true; // 拿不到指纹 ⇒ 只能全量 } const mods: Record = fpResult.modules !== undefined ? fpResult.modules : {}; const serverFp: string | null = mods[name] !== undefined ? mods[name] : null; if (serverFp === null || serverFp === '') { return true; // 服务端判断不了这个模块 ⇒ 老实拉 } const mark: FbModMark | undefined = marks.get(name); if (mark === undefined) { return true; // 本地没记录(新装/刚升级)⇒ 首次全量 } if (Date.now() - mark.fullAt > FORCE_FULL_AFTER_MS) { return true; // 陈旧上限到了(见文件头) } return mark.fp !== serverFp; }; // ---- ② 观影 ---- let anyFetched: boolean = false; // 有任何模块真拉了 ⇒ 首页可能变 if (plan('movies')) { anyFetched = true; await step(MODULE_CN['movies'], async (): Promise => { const r: FbSyncResult = await FbMovieRepo.inst().sync(token); if (r.ok) { await saveMark('movies', fpOf(fpResult, 'movies')); } return r.ok; }); } else { skip(MODULE_CN['movies']); } // ---- ③ 名册 ---- if (plan('roster')) { anyFetched = true; await step(MODULE_CN['roster'], async (): Promise => { const r: FbRosterSyncResult = await FbPersonRepo.inst().sync(token); if (r.ok) { await saveMark('roster', fpOf(fpResult, 'roster')); } return r.ok; }); } else { skip(MODULE_CN['roster']); } // ---- ④ 其余 9 个模块(走通用缓存)---- const repo: FbModuleRepo = FbModuleRepo.inst(); for (const name of CACHE_MODULES) { const label: string = MODULE_CN[name] !== undefined ? MODULE_CN[name] : name; const path: string = FB_MODULE_PATH[name] !== undefined ? FB_MODULE_PATH[name] : ''; if (path === '') { skip(label); continue; } if (!plan(name)) { skip(label); continue; } anyFetched = true; await step(label, async (): Promise => { const ok: boolean = await loadOne(repo, name, path); if (ok) { await saveMark(name, fpOf(fpResult, name)); } return ok; }); } // ---- ⑤ 首页(派生页)---- /* home 不在服务端的指纹里(它由上面这些模块拼出来)。 规则:只要有任何一个模块真拉了,首页就可能变 ⇒ 跟着拉; 全都没变 ⇒ 首页也不会变 ⇒ 跳过。这样日常打开 App 首页也不会白拉一次。 */ if (anyFetched) { const homePath: string = FB_MODULE_PATH['home'] !== undefined ? FB_MODULE_PATH['home'] : ''; if (homePath === '') { skip(MODULE_CN['home']); } else { await step(MODULE_CN['home'], async (): Promise => { return await loadOne(repo, 'home', homePath); }); } } else { skip(MODULE_CN['home']); } // ---- ⑥ 收尾:附件后台队列(不阻塞同步完成)---- collectAttachKeys().then((): void => { // ⚠️ 不 await:附件可能几百个,主界面不该等它 FB_ATTACH.run(); }).catch((err: Object): void => { hilog.warn(DOMAIN, TAG, '附件键收集失败:%{public}s', JSON.stringify(err)); }); const skipped: number = st.skipped; const note: string = failed.length === 0 ? (skipped > 0 ? ('已是最新:' + done.toString() + ' 个模块,其中 ' + skipped.toString() + ' 个没有变化') : ('全部 ' + done.toString() + ' 个模块同步完成')) : (done.toString() + '/' + total.toString() + ' 完成,失败:' + failed.join('、')); st.failed = failed; st.finish(note); const result: FbSyncAllResult = { ok: failed.length === 0, done: done, total: total, failed: failed, note: note, skipped: skipped, }; return result; } /* ═══════════════════════════════════════════════════════════════ 内部工具 ═══════════════════════════════════════════════════════════════ */ /** 拉一个模块的通用缓存(等网络那次回来才算结果) */ function loadOne(repo: FbModuleRepo, name: string, path: string): Promise { return new Promise((resolve: (v: boolean) => void): void => { let settled: boolean = false; repo.load(name, path, (r: FbModuleLoad): void => { if (r.fromCache || settled) { return; // 缓存那次不算结果 } settled = true; resolve(r.error === ''); }); // 兜底超时(网络卡住时不能让全量同步永远挂着) setTimeout((): void => { if (!settled) { settled = true; resolve(false); } }, 20000); }); } /** 读本地 `module_sync` 记录 */ async function loadMarks(): Promise> { const out: Map = new Map(); const db: FbDb = FbDb.inst(); if (!db.isReady()) { return out; } try { const rows: FbRow[] = await db.queryRows('SELECT name, fp, full_at FROM module_sync'); for (const r of rows) { const m: FbModMark = { fp: r.strOr('fp', ''), fullAt: r.num('full_at', 0) }; out.set(r.strOr('name', ''), m); } } catch (err) { hilog.warn(DOMAIN, TAG, '读同步水位失败(当作首次全量):%{public}s', JSON.stringify(err)); } return out; } /** 写一条模块水位 */ async function saveMark(name: string, fp: string): Promise { const db: FbDb = FbDb.inst(); if (!db.isReady()) { return; } try { await db.exec( 'INSERT OR REPLACE INTO module_sync (name, fp, synced_at, full_at) VALUES (?,?,?,?)', [name, fp, Date.now(), Date.now()]); } catch (err) { hilog.warn(DOMAIN, TAG, '写同步水位失败:%{public}s', JSON.stringify(err)); } } /** 从指纹结果里取某模块的指纹(拿不到返回空串 = 下次还会拉) */ function fpOf(data: FbChangesData | null, name: string): string { if (data === null || data.modules === undefined) { return ''; } const v: string | null | undefined = data.modules[name]; return v !== undefined && v !== null ? v : ''; } /** * 打 `/changes`。 * * @returns 数据(可能是 null:网络不通 / 接口不可用 —— 都由调用方退化为全量) */ async function fetchChanges(token: string): Promise { const opts: FbApiOptions = { method: 'GET', token: token, authProbe: true }; const r: FbApiResult = await fbApi('/api/v1/changes', opts); if (!r.ok) { hilog.info(DOMAIN, TAG, '取变更指纹失败(%{public}s),本轮退化为全量', r.error); return null; } try { const env: FbChangesEnvelope = JSON.parse(r.data) as FbChangesEnvelope; return env.data !== undefined ? env.data : null; } catch (err) { return null; } } /** * 服务端到底通不通(只看"能不能打到一个不需要令牌的接口")。 * * ⚠️ 判据是 **HTTP 状态码是否大于 0**,不是"返回 ok": * 老服务端打 `/changes` 会回 404(有状态码、没这个接口),那属于"服务端在,只是不支持"; * 真断网时 `fbApi` 给的 status 是 0(它在 catch 里返回 `status: 0`)。 * 用 `ok` 判会把"老服务端"误判成"断网",直接不同步了。 */ async function pingReachable(): Promise { const opts: FbApiOptions = { method: 'GET', authProbe: true }; const r: FbApiResult = await fbApi('/api/v1/ping', opts); return r.status > 0; } /** * 把"已经落地的数据"里出现的附件键登记进队列。 * * 来源三处(都在本地,不额外发请求): * · `module_cache.payload` —— 9 个模块的完整 JSON(递归扫,见 `FbAttachQueue.collectFromJson`); * · `movie_details.poster_key` / `ticket_key` —— 海报与票根; * · `persons.avatar` —— 名册头像。 */ async function collectAttachKeys(): Promise { const db: FbDb = FbDb.inst(); if (!db.isReady()) { return; } const keys: string[] = []; try { const caches: FbRow[] = await db.queryRows('SELECT payload FROM module_cache'); for (const r of caches) { FB_ATTACH.collectFromJson(r.strOr('payload', ''), keys); } const posters: FbRow[] = await db.queryRows( "SELECT poster_key, ticket_key FROM movie_details " + "WHERE (poster_key IS NOT NULL AND poster_key <> '') " + " OR (ticket_key IS NOT NULL AND ticket_key <> '')"); for (const r of posters) { const p: string = r.strOr('poster_key', ''); const t: string = r.strOr('ticket_key', ''); if (p !== '') { keys.push(p); } if (t !== '') { keys.push(t); } } const avatars: FbRow[] = await db.queryRows( "SELECT avatar FROM persons WHERE avatar IS NOT NULL AND avatar <> ''"); for (const r of avatars) { const a: string = r.strOr('avatar', ''); if (a !== '') { keys.push(a); } } } catch (err) { hilog.warn(DOMAIN, TAG, '收集附件键失败:%{public}s', JSON.stringify(err)); } // 去重后登记 const uniq: string[] = []; const seen: Set = new Set(); for (const k of keys) { if (!seen.has(k)) { seen.add(k); uniq.push(k); } } const n: number = await FB_ATTACH.collectKeys(uniq); hilog.info(DOMAIN, TAG, '发现附件 %{public}d 个(新登记 %{public}d)', uniq.length, n); }