v0.8.0 本地加密 / 被踢清数据 / 增量同步 / 附件队列 / 顶栏同步进度
用户 2026-10-18 两条: 「直接做 L3 锁界面 + 本地加密(HUKS,踢设备即吊销密钥):数据在但读不出, 重登后恢复密钥即可用。」 「右上角头像和提醒前面加同步进度…首次全量、之后增量;进来先展示本地数据, 一边同步一边更新;附件后台队列同步(名册头像 / 电影海报 / 观影照片 / 资源附件)。」 ① 整库加密(FbSchema v4→v5,新增 module_sync / attach_cache 两张表) RDB encrypt=true。加密属性一经创建不可修改 ⇒ openWithEncryption 三段式: 直接开 → 失败删库重建 → 再失败降级明文(degraded 字段,设置页如实显示, 不静默降级)。 ② 两层密钥(FbKeyStore) HUKS 里 KEK(fb_kek_v1,不可导出)包裹 32 字节 DEK;revoke() 删 KEK ⇒ 已落盘附件密文在数学上解不开,这就是"踢设备即吊销密钥"。 重新登录重新生成 DEK 并重下附件(不做服务端托管,为了省一次重下 把能解你本地数据的密钥放到服务端不值)。 会话属性里 PURPOSE 只出现一次:原来把含 ENCRYPT|DECRYPT 的生成属性直接 拿去开会话再 push 一个 PURPOSE,同一 tag 出现两次在 HUKS 里行为未定义 ⇒ 拆成 kekBaseProps / kekGenProps / kekSessionProps。 ③ AES-256-GCM(FbCrypto) 落盘 iv(12)|ct|tag(16);变换串必须带 padding 段(AES256|GCM|PKCS7, 缺了抛 801);解密从尾部切 tag。 ④ 附件后台队列(FbAttachQueue) attach_cache 台账(pending/done/fail,MAX_TRIES=3);密文放 filesDir、 明文临时放 cacheDir;扩展名白名单筛 key。 图片地址收成统一入口 fbImageSrc(key,token):本地有就用 file://, 没有才回网络 —— 收成一个函数才漏不掉(5 个页面全部改过来)。 ⑤ 被踢 / 退出清数据 lockOut → 清令牌 → FB_ATTACH.wipe(第一步就是吊销 KEK)→ FbDb.wipe (先关句柄再 deleteRdbStore,并发去重,open 会等它)→ 同步状态复位。 重新登录后重建密钥 + 库 + 附件索引,否则之后只读网络不写缓存且不报错。 ⑥ 增量同步(FbSyncAll 重写) 先问 /api/v1/changes;指纹相同就跳过。本地没水位必须拉(否则老用户升级后 一个模块都不同步);24h 陈旧上限兜"改无 updated_at 表的已有行"; /changes 拿不到时判 ping status>0 —— 断网直接收工,不退回全量 (否则 12 个模块各等 15 秒)。 家谱也进了预同步列表(原来只在打开时拉,离线是一片空白); 总步数由 CACHE_MODULES.length 算出,不写死。 ⑦ 删除传播 服务端是物理删除、没有墓碑 ⇒ 观影同步改成"先清后插" (persons 跨模块共用,不清)。 ⑧ 顶栏同步进度 头像与铃铛之间加三态按钮(转圈 / 红感叹号 / 灰对勾)+ bindSheet 面板: 进度条、「N 个模块没有变化」、附件行、失败清单、立即同步(防连点)。 状态中心 FbSyncState(@ObservedV2 + @Trace,AppStorageV2 单例); 界面直接读 @Trace 字段,不提供派生 getter。 验证:hvigor BUILD SUCCESSFUL;_fb_arkts_guard.js 290/0(本批新增 59 条); _fb_v58_falsify.py 31 项注入 38/0;b57 反向验证 12/0(锚点跟上本批 logout 改动); 本地 e2e 1519/0;全量流水线 PUSH_RC=0。
This commit is contained in:
@@ -1,36 +1,59 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 全量同步(鸿蒙端)
|
||||
*
|
||||
* 一次把所有模块都同步一遍。
|
||||
* 家书 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 只拉变了的那一两个模块"。
|
||||
*
|
||||
* ⚠️ 它与"页面各自同步"**不冲突**,两者都要有:
|
||||
* · 页面各自同步:保证"刚进这一页就有最新数据";
|
||||
* · 全量同步:保证"不用点遍每一页"。
|
||||
* 两条路的终点是同一个缓存(`module_cache` / 实体表),所以谁先谁后都对。
|
||||
*/
|
||||
|
||||
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;
|
||||
@@ -38,17 +61,18 @@ export interface FbSyncAllResult {
|
||||
failed: string[];
|
||||
/** 当前进度文案(同步中实时更新用) */
|
||||
note: string;
|
||||
/** 本次因"服务端说没变"而跳过的模块数(增量生效的证据) */
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量同步的实时进度(供 UI 显示)。
|
||||
* 进度回调(可选)。
|
||||
*
|
||||
* ⚠️ 为什么用一个**模块级变量**而不是回调:
|
||||
* 本机设备 `hilog` 读不出内容(已知限制),
|
||||
* 同步又可能跑十几秒 —— 页面必须能显示"现在到哪一步了",
|
||||
* 否则用户以为卡死。用轮询读这个变量的方式最省事(见设置页)。
|
||||
* 🔴 为什么还要保留这个参数(v59 已经有 `FbSyncState` 了):
|
||||
* 登录页是"用户正盯着屏幕等首次同步"的场景,它需要一个**从函数里推出来**的回调;
|
||||
* 而 `FbSyncState` 是给顶栏/面板这种"订阅制"的地方用的。两者不冲突。
|
||||
*/
|
||||
export let FB_SYNC_ALL_PROGRESS: string = '';
|
||||
export type FbSyncStepCb = (label: string, done: number, total: number) => void;
|
||||
|
||||
/** 模块的中文名(进度提示用) */
|
||||
const MODULE_CN: Record<string, string> = {
|
||||
@@ -63,42 +87,68 @@ const MODULE_CN: Record<string, string> = {
|
||||
'books': '藏书',
|
||||
'notes': '笔记',
|
||||
'notifications': '通知',
|
||||
'kin': '家谱',
|
||||
'home': '首页',
|
||||
};
|
||||
|
||||
/**
|
||||
* 进度回调(可选)。
|
||||
* 走通用缓存的模块(观影 / 名册有实体表,单独走)。
|
||||
*
|
||||
* 🔴 为什么加这个参数,而不是让调用方去轮询 `FB_SYNC_ALL_PROGRESS`:
|
||||
* 模块级 `export let` 的"实时值"依赖打包器是否保留 live binding ——
|
||||
* 这在 esmodule / 混淆 / tree-shaking 下**不保证**,一旦被内联成常量,
|
||||
* 进度条就永远停在初始值(而调用方还以为在正常工作)。
|
||||
* ⇒ 登录页这种"用户正盯着屏幕等"的场景,进度必须**从函数里推出来**。
|
||||
*
|
||||
* @param label 正在同步的模块中文名(如「观影」)
|
||||
* @param done 已完成的模块数(**不含**当前这个)
|
||||
* @param total 模块总数
|
||||
* 🔴 `kin`(家谱)**必须在**这里(v59 补的):用户要的是"把所有数据都同步下来",
|
||||
* 而家谱页原来只在**打开时**才拉 —— 离线进去是一片空白。
|
||||
* 它的数据来源与名册同源(`persons` / `relations` / `nicknames`),
|
||||
* 所以服务端 `CHANGE_TABLES['kin']` 用的是同一批表:名册一变,家谱跟着重拉。
|
||||
*/
|
||||
export type FbSyncStepCb = (label: string, done: number, total: number) => void;
|
||||
const CACHE_MODULES: string[] = [
|
||||
'calendar', 'tasks', 'days', 'trips', 'ledger', 'resources', 'books', 'notes',
|
||||
'notifications', 'kin',
|
||||
];
|
||||
|
||||
/** `GET /api/v1/changes` 的响应 */
|
||||
interface FbChangesData {
|
||||
server_time?: string;
|
||||
/** 模块名 → 指纹(null = 服务端判断不了) */
|
||||
modules?: Record<string, string | null>;
|
||||
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<FbSyncAllResult> {
|
||||
const token: string = FB_TOKEN_STORE.get();
|
||||
const failed: string[] = [];
|
||||
const st: FbSyncState = fbSyncState();
|
||||
|
||||
if (token === '') {
|
||||
st.failAll('未登录,无法同步');
|
||||
const r: FbSyncAllResult = {
|
||||
ok: false, done: 0, total: 12, failed: [],
|
||||
note: '未登录,无法同步',
|
||||
ok: false, done: 0, total: 1 + 1 + CACHE_MODULES.length + 1,
|
||||
failed: [], note: '未登录,无法同步', skipped: 0,
|
||||
};
|
||||
FB_SYNC_ALL_PROGRESS = r.note;
|
||||
return r;
|
||||
}
|
||||
|
||||
// 总步数**由模块表算出来**(观影 + 名册 + 缓存模块 + 首页;跳过也占一步,百分比才准)。
|
||||
// 🔴 别写死数字:v59 加 `kin` 时写死的 12 就会让进度条最后停在 11/12
|
||||
// —— 写死常量的进度和真实的循环次数是两套账,迟早对不上。
|
||||
const total: number = 1 + 1 + CACHE_MODULES.length + 1;
|
||||
st.begin(total);
|
||||
let done: number = 0;
|
||||
const total: number = 12;
|
||||
const failed: string[] = [];
|
||||
|
||||
/** 跑一个模块并记账 */
|
||||
const step = async (label: string, work: () => Promise<boolean>): Promise<void> => {
|
||||
FB_SYNC_ALL_PROGRESS = label;
|
||||
st.step(label, done, total);
|
||||
if (onStep !== undefined) {
|
||||
onStep(label, done, total);
|
||||
}
|
||||
@@ -108,69 +158,319 @@ export async function syncAll(onStep?: FbSyncStepCb): Promise<FbSyncAllResult> {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// ---- ① 有实体表的两个模块(先做:它们的数据量大、最该先落地)----
|
||||
await step(MODULE_CN['movies'], async (): Promise<boolean> => {
|
||||
const r: FbSyncResult = await FbMovieRepo.inst().sync(token);
|
||||
return r.ok;
|
||||
});
|
||||
/** 记一步"服务端说没变,跳过" */
|
||||
const skip = (label: string): void => {
|
||||
st.step(label + '(没变化)', done, total);
|
||||
if (onStep !== undefined) {
|
||||
onStep(label, done, total);
|
||||
}
|
||||
st.skipOne();
|
||||
done += 1;
|
||||
};
|
||||
|
||||
await step(MODULE_CN['roster'], async (): Promise<boolean> => {
|
||||
const r: FbRosterSyncResult = await FbPersonRepo.inst().sync(token);
|
||||
return r.ok;
|
||||
});
|
||||
// ---- ① 问一句"哪些模块变了" ----
|
||||
const marks: Map<string, FbModMark> = 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, '服务端不支持变更指纹,退回全量同步');
|
||||
}
|
||||
|
||||
// ---- ② 其余模块(走通用缓存)----
|
||||
const cacheModules: string[] = [
|
||||
'calendar', 'tasks', 'days', 'trips', 'ledger', 'resources', 'books', 'notes',
|
||||
'notifications', 'home',
|
||||
];
|
||||
/** 该查什么:true = 需要拉 */
|
||||
const plan = (name: string): boolean => {
|
||||
if (fpResult === null) {
|
||||
return true; // 拿不到指纹 ⇒ 只能全量
|
||||
}
|
||||
const mods: Record<string, string | null> = 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<boolean> => {
|
||||
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<boolean> => {
|
||||
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 cacheModules) {
|
||||
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<boolean> => {
|
||||
// 这里只关心"有没有成功写进缓存":`load` 的 sink 回调是同步的,
|
||||
// 用一个 Promise 把它包起来,等到网络那次回来(fromCache=false)才算成功。
|
||||
const ok: boolean = await new Promise<boolean>((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);
|
||||
});
|
||||
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<boolean> => {
|
||||
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: failed.length === 0
|
||||
? ('全部 ' + done.toString() + ' 个模块同步完成')
|
||||
: (done.toString() + '/' + total.toString() + ' 完成,失败:' + failed.join('、')),
|
||||
note: note,
|
||||
skipped: skipped,
|
||||
};
|
||||
FB_SYNC_ALL_PROGRESS = result.note;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
内部工具
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/** 拉一个模块的通用缓存(等网络那次回来才算结果) */
|
||||
function loadOne(repo: FbModuleRepo, name: string, path: string): Promise<boolean> {
|
||||
return new Promise<boolean>((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<Map<string, FbModMark>> {
|
||||
const out: Map<string, FbModMark> = new Map<string, FbModMark>();
|
||||
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<void> {
|
||||
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<FbChangesData | null> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<string> = new Set<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user