2026-10-18 03:35:39 +08:00
|
|
|
|
/**
|
2026-10-18 16:01:41 +08:00
|
|
|
|
* 家书 FamilyBook · 全量 / 增量同步(鸿蒙端)
|
2026-10-18 03:35:39 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 🔴 为什么需要它(用户 2026-10-12:"把同步做成全局的,接进来一个模块,
|
|
|
|
|
|
* 就自动同步一个模块"):
|
2026-10-18 16:01:41 +08:00
|
|
|
|
* 之前每个页面**自己同步自己**,结果是"用户必须把每个页面都点一遍才拿到全量数据",
|
|
|
|
|
|
* 而且设置页那个「保存并同步」只同步了观影 —— 一个入口只干一部分活,用户没法理解。
|
2026-10-18 03:35:39 +08:00
|
|
|
|
* ⇒ 加这个"全量同步":**一处触发、全部模块都更新**。
|
|
|
|
|
|
*
|
2026-10-18 16:01:41 +08:00
|
|
|
|
* 🔴 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 只拉变了的那一两个模块"。
|
|
|
|
|
|
*
|
2026-10-18 03:35:39 +08:00
|
|
|
|
* ⚠️ 它与"页面各自同步"**不冲突**,两者都要有:
|
|
|
|
|
|
* · 页面各自同步:保证"刚进这一页就有最新数据";
|
|
|
|
|
|
* · 全量同步:保证"不用点遍每一页"。
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { hilog } from '@kit.PerformanceAnalysisKit';
|
|
|
|
|
|
import { FbMovieRepo, FbSyncResult } from './FbMovieRepo';
|
|
|
|
|
|
import { FbPersonRepo, FbRosterSyncResult } from './FbPersonRepo';
|
2026-10-18 16:01:41 +08:00
|
|
|
|
import { FbDb, FbRow } from './FbDb';
|
2026-10-18 03:35:39 +08:00
|
|
|
|
import { FbModuleLoad, FbModuleRepo } from './FbModuleRepo';
|
|
|
|
|
|
import { FB_MODULE_PATH } from './FbModuleLoad';
|
|
|
|
|
|
import { FB_TOKEN_STORE } from './FbTokenStore';
|
2026-10-18 16:01:41 +08:00
|
|
|
|
import { FbApiOptions, FbApiResult, fbApi } from './FbHttp';
|
|
|
|
|
|
import { FB_ATTACH } from './FbAttachQueue';
|
|
|
|
|
|
import { FbSyncState, fbSyncState } from '../common/FbSyncStore';
|
2026-10-18 03:35:39 +08:00
|
|
|
|
|
|
|
|
|
|
const DOMAIN: number = 0x0000;
|
|
|
|
|
|
const TAG: string = 'FbSyncAll';
|
|
|
|
|
|
|
2026-10-18 16:01:41 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 陈旧上限:距上次**真正拉取**超过这么久,就无条件全量一次。
|
|
|
|
|
|
* 理由见文件头"陈旧上限不是补丁"那段。
|
|
|
|
|
|
*/
|
|
|
|
|
|
const FORCE_FULL_AFTER_MS: number = 24 * 3600 * 1000;
|
|
|
|
|
|
|
2026-10-18 03:35:39 +08:00
|
|
|
|
/** 全量同步结果 */
|
|
|
|
|
|
export interface FbSyncAllResult {
|
|
|
|
|
|
ok: boolean;
|
2026-10-18 16:01:41 +08:00
|
|
|
|
/** 成功同步的模块数(**含**因"没变化"而跳过的) */
|
2026-10-18 03:35:39 +08:00
|
|
|
|
done: number;
|
|
|
|
|
|
/** 总模块数 */
|
|
|
|
|
|
total: number;
|
|
|
|
|
|
/** 失败模块名(用户看得到的人话) */
|
|
|
|
|
|
failed: string[];
|
|
|
|
|
|
/** 当前进度文案(同步中实时更新用) */
|
|
|
|
|
|
note: string;
|
2026-10-18 16:01:41 +08:00
|
|
|
|
/** 本次因"服务端说没变"而跳过的模块数(增量生效的证据) */
|
|
|
|
|
|
skipped: number;
|
2026-10-18 03:35:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-18 16:01:41 +08:00
|
|
|
|
* 进度回调(可选)。
|
2026-10-18 03:35:39 +08:00
|
|
|
|
*
|
2026-10-18 16:01:41 +08:00
|
|
|
|
* 🔴 为什么还要保留这个参数(v59 已经有 `FbSyncState` 了):
|
|
|
|
|
|
* 登录页是"用户正盯着屏幕等首次同步"的场景,它需要一个**从函数里推出来**的回调;
|
|
|
|
|
|
* 而 `FbSyncState` 是给顶栏/面板这种"订阅制"的地方用的。两者不冲突。
|
2026-10-18 03:35:39 +08:00
|
|
|
|
*/
|
2026-10-18 16:01:41 +08:00
|
|
|
|
export type FbSyncStepCb = (label: string, done: number, total: number) => void;
|
2026-10-18 03:35:39 +08:00
|
|
|
|
|
|
|
|
|
|
/** 模块的中文名(进度提示用) */
|
|
|
|
|
|
const MODULE_CN: Record<string, string> = {
|
|
|
|
|
|
'movies': '观影',
|
|
|
|
|
|
'roster': '名册',
|
|
|
|
|
|
'calendar': '日程',
|
|
|
|
|
|
'tasks': '任务',
|
|
|
|
|
|
'days': '纪念日',
|
|
|
|
|
|
'trips': '旅程',
|
|
|
|
|
|
'ledger': '账本',
|
|
|
|
|
|
'resources': '资源',
|
|
|
|
|
|
'books': '藏书',
|
|
|
|
|
|
'notes': '笔记',
|
|
|
|
|
|
'notifications': '通知',
|
|
|
|
|
|
'home': '首页',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-18 16:01:41 +08:00
|
|
|
|
* 走通用缓存的模块(观影 / 名册有实体表,单独走)。
|
2026-10-18 03:35:39 +08:00
|
|
|
|
*
|
2026-10-21 23:28:19 +08:00
|
|
|
|
* ⚠️ v74:`kin`(家谱)已从端上删除,这里一并移除 —— 否则每次全量同步
|
|
|
|
|
|
* 都会去拉一个端上已经没有任何页面消费的接口。
|
2026-10-18 03:35:39 +08:00
|
|
|
|
*/
|
2026-10-18 16:01:41 +08:00
|
|
|
|
const CACHE_MODULES: string[] = [
|
|
|
|
|
|
'calendar', 'tasks', 'days', 'trips', 'ledger', 'resources', 'books', 'notes',
|
2026-10-21 23:28:19 +08:00
|
|
|
|
'notifications',
|
2026-10-18 16:01:41 +08:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
/** `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;
|
|
|
|
|
|
}
|
2026-10-18 03:35:39 +08:00
|
|
|
|
|
|
|
|
|
|
export async function syncAll(onStep?: FbSyncStepCb): Promise<FbSyncAllResult> {
|
|
|
|
|
|
const token: string = FB_TOKEN_STORE.get();
|
2026-10-18 16:01:41 +08:00
|
|
|
|
const st: FbSyncState = fbSyncState();
|
|
|
|
|
|
|
2026-10-18 03:35:39 +08:00
|
|
|
|
if (token === '') {
|
2026-10-18 16:01:41 +08:00
|
|
|
|
st.failAll('未登录,无法同步');
|
2026-10-18 03:35:39 +08:00
|
|
|
|
const r: FbSyncAllResult = {
|
2026-10-18 16:01:41 +08:00
|
|
|
|
ok: false, done: 0, total: 1 + 1 + CACHE_MODULES.length + 1,
|
|
|
|
|
|
failed: [], note: '未登录,无法同步', skipped: 0,
|
2026-10-18 03:35:39 +08:00
|
|
|
|
};
|
|
|
|
|
|
return r;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 16:01:41 +08:00
|
|
|
|
// 总步数**由模块表算出来**(观影 + 名册 + 缓存模块 + 首页;跳过也占一步,百分比才准)。
|
|
|
|
|
|
// 🔴 别写死数字:v59 加 `kin` 时写死的 12 就会让进度条最后停在 11/12
|
|
|
|
|
|
// —— 写死常量的进度和真实的循环次数是两套账,迟早对不上。
|
|
|
|
|
|
const total: number = 1 + 1 + CACHE_MODULES.length + 1;
|
|
|
|
|
|
st.begin(total);
|
2026-10-18 03:35:39 +08:00
|
|
|
|
let done: number = 0;
|
2026-10-18 16:01:41 +08:00
|
|
|
|
const failed: string[] = [];
|
2026-10-18 03:35:39 +08:00
|
|
|
|
|
|
|
|
|
|
/** 跑一个模块并记账 */
|
|
|
|
|
|
const step = async (label: string, work: () => Promise<boolean>): Promise<void> => {
|
2026-10-18 16:01:41 +08:00
|
|
|
|
st.step(label, done, total);
|
2026-10-18 03:35:39 +08:00
|
|
|
|
if (onStep !== undefined) {
|
|
|
|
|
|
onStep(label, done, total);
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const ok: boolean = await work();
|
|
|
|
|
|
if (ok) {
|
|
|
|
|
|
done += 1;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
failed.push(label);
|
2026-10-18 16:01:41 +08:00
|
|
|
|
done += 1;
|
2026-10-18 03:35:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
hilog.warn(DOMAIN, TAG, '同步 %{public}s 失败:%{public}s', label, JSON.stringify(err));
|
|
|
|
|
|
failed.push(label);
|
2026-10-18 16:01:41 +08:00
|
|
|
|
done += 1;
|
2026-10-18 03:35:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-18 16:01:41 +08:00
|
|
|
|
/** 记一步"服务端说没变,跳过" */
|
|
|
|
|
|
const skip = (label: string): void => {
|
|
|
|
|
|
st.step(label + '(没变化)', done, total);
|
|
|
|
|
|
if (onStep !== undefined) {
|
|
|
|
|
|
onStep(label, done, total);
|
|
|
|
|
|
}
|
|
|
|
|
|
st.skipOne();
|
|
|
|
|
|
done += 1;
|
|
|
|
|
|
};
|
2026-10-18 03:35:39 +08:00
|
|
|
|
|
2026-10-18 16:01:41 +08:00
|
|
|
|
// ---- ① 问一句"哪些模块变了" ----
|
|
|
|
|
|
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, '服务端不支持变更指纹,退回全量同步');
|
|
|
|
|
|
}
|
2026-10-18 03:35:39 +08:00
|
|
|
|
|
2026-10-18 16:01:41 +08:00
|
|
|
|
/** 该查什么: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 个模块(走通用缓存)----
|
2026-10-18 03:35:39 +08:00
|
|
|
|
const repo: FbModuleRepo = FbModuleRepo.inst();
|
2026-10-18 16:01:41 +08:00
|
|
|
|
for (const name of CACHE_MODULES) {
|
2026-10-18 03:35:39 +08:00
|
|
|
|
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 === '') {
|
2026-10-18 16:01:41 +08:00
|
|
|
|
skip(label);
|
2026-10-18 03:35:39 +08:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-10-18 16:01:41 +08:00
|
|
|
|
if (!plan(name)) {
|
|
|
|
|
|
skip(label);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
anyFetched = true;
|
2026-10-18 03:35:39 +08:00
|
|
|
|
await step(label, async (): Promise<boolean> => {
|
2026-10-18 16:01:41 +08:00
|
|
|
|
const ok: boolean = await loadOne(repo, name, path);
|
|
|
|
|
|
if (ok) {
|
|
|
|
|
|
await saveMark(name, fpOf(fpResult, name));
|
|
|
|
|
|
}
|
2026-10-18 03:35:39 +08:00
|
|
|
|
return ok;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 16:01:41 +08:00
|
|
|
|
// ---- ⑤ 首页(派生页)----
|
|
|
|
|
|
/* 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);
|
|
|
|
|
|
|
2026-10-18 03:35:39 +08:00
|
|
|
|
const result: FbSyncAllResult = {
|
|
|
|
|
|
ok: failed.length === 0,
|
|
|
|
|
|
done: done,
|
|
|
|
|
|
total: total,
|
|
|
|
|
|
failed: failed,
|
2026-10-18 16:01:41 +08:00
|
|
|
|
note: note,
|
|
|
|
|
|
skipped: skipped,
|
2026-10-18 03:35:39 +08:00
|
|
|
|
};
|
|
|
|
|
|
return result;
|
2026-10-18 16:01:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
内部工具
|
|
|
|
|
|
═══════════════════════════════════════════════════════════════ */
|
|
|
|
|
|
|
|
|
|
|
|
/** 拉一个模块的通用缓存(等网络那次回来才算结果) */
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|