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:
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 附件后台同步队列(鸿蒙端,v59)
|
||||
*
|
||||
* 🔴 用户 2026-10-17 的原话:
|
||||
* 「使用同步的功能,把所有的数据全部同步到我们的这个应用下面,包括一些附件,
|
||||
* 比方说名册里面的头像啊,电影观影模块里面的这种海报啊,观影照片啊,
|
||||
* 还有资源里面的一些附件啊等等,所有的这些全部同步下来。」
|
||||
* 「这个时间可能会比较久,所以应该在那个右上角的……加一个同步的进度的这样一个按钮」
|
||||
*
|
||||
* 三件事:
|
||||
* ① **找**:从已经落地的数据里把附件相对键扫出来(见 `collectFromJson`);
|
||||
* ② **下**:逐个下载,AES-GCM 加密后存沙箱(`filesDir/attachments`);
|
||||
* ③ **给界面**:解密一份到 `cacheDir/fb_img`,`localPath(key)` **同步**返回
|
||||
* `file://` 路径,界面直接 `Image(...)` 用,不额外转圈。
|
||||
*
|
||||
* 🔴 为什么加密:
|
||||
* 附件(头像、观影照片)是用户点名要"同步下来"的**私人**内容。
|
||||
* 库已经整库加密(RDB `encrypt`),附件如果明文躺在沙箱里,
|
||||
* "被踢之后什么都读不出"就有个洞 —— 拷走 attachments 目录就全拿到了。
|
||||
* ⇒ 密文存在 `filesDir`,明文只出现在 `cacheDir`(系统可随时清理,被踢时主动清)。
|
||||
*
|
||||
* 🔴 为什么**顺序**下载、不做并发:
|
||||
* 线上全量附件目前是 157 个文件 / 13MB 量级,顺序 2~3 分钟;
|
||||
* 而顺序实现让"断点续传/失败退避/进度百分比"都变成一件简单的事。
|
||||
* 并发只会让这三件事各复杂一倍,收益却几乎看不到。
|
||||
* ⚠️ 将来附件量真上去了,只需把 `run()` 里那个 for 换成 2~3 路 worker。
|
||||
*
|
||||
* ⚠️ 网络地址仍走 `media.php?k=<key>&token=<token>`(见 `fbImageUrl`):
|
||||
* 这是服务端唯一的附件出口,`Image()` 加不了请求头,令牌只能放 query。
|
||||
*/
|
||||
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { fileIo } from '@kit.CoreFileKit';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { cryptoFramework } from '@kit.CryptoArchitectureKit';
|
||||
import { util } from '@kit.ArkTS';
|
||||
import { FbDb, FbRow } from './FbDb';
|
||||
import { FB_KEY_STORE } from './FbKeyStore';
|
||||
import { fbAesDecrypt, fbAesEncrypt } from './FbCrypto';
|
||||
import { fbImageUrl } from './FbHttp';
|
||||
import { FB_TOKEN_STORE } from './FbTokenStore';
|
||||
import { fbSyncState } from '../common/FbSyncStore';
|
||||
|
||||
const DOMAIN: number = 0x0000;
|
||||
const TAG: string = 'FbAttach';
|
||||
|
||||
/** 密文目录(`filesDir` 下,不会被系统清理) */
|
||||
const DIR_ENC: string = 'attachments';
|
||||
/** 明文临时目录(`cacheDir` 下,系统可清、被踢时主动清) */
|
||||
const DIR_TMP: string = 'fb_img';
|
||||
|
||||
/** 同一张图最多重试几次(超过就标 fail,不再自动撞) */
|
||||
const MAX_TRIES: number = 3;
|
||||
|
||||
/** 只有这些扩展名才当成附件 —— 防止把 `source_ref` 之类误当文件下载 */
|
||||
const EXT_WHITELIST: string[] = [
|
||||
'jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'heic',
|
||||
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
||||
'zip', 'rar', '7z', 'mp4', 'mov', 'mp3', 'wav', 'txt', 'md'
|
||||
];
|
||||
|
||||
/** 队列里的一行 */
|
||||
interface FbAttachItem {
|
||||
key: string;
|
||||
tries: number;
|
||||
}
|
||||
|
||||
class FbAttachQueueImpl {
|
||||
private dirEnc: string = '';
|
||||
private dirTmp: string = '';
|
||||
/** key → 可直接给 `Image` 的本地明文路径(`file://...`) */
|
||||
private localMap: Map<string, string> = new Map<string, string>();
|
||||
private dirsReady: boolean = false;
|
||||
private running: boolean = false;
|
||||
|
||||
/**
|
||||
* 建目录 + 恢复内存索引(启动时调一次)。
|
||||
*
|
||||
* 🔴 为什么要"恢复索引"而不是每次显示时查表:
|
||||
* `Image()` 在 build 里要**同步**拿路径。查库是异步的,来不及。
|
||||
* ⇒ 内存里存一份 Map,只在 `init` 和下载完成时更新。
|
||||
*/
|
||||
async init(ctx: common.Context): Promise<void> {
|
||||
if (this.dirsReady) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.dirEnc = ctx.filesDir + '/' + DIR_ENC;
|
||||
this.dirTmp = ctx.cacheDir + '/' + DIR_TMP;
|
||||
if (!fileIo.accessSync(this.dirEnc)) {
|
||||
fileIo.mkdirSync(this.dirEnc, true);
|
||||
}
|
||||
if (!fileIo.accessSync(this.dirTmp)) {
|
||||
fileIo.mkdirSync(this.dirTmp, true);
|
||||
}
|
||||
this.dirsReady = true;
|
||||
} catch (err) {
|
||||
hilog.error(DOMAIN, TAG, '建附件目录失败:%{public}s', JSON.stringify(err));
|
||||
return;
|
||||
}
|
||||
await this.rebuildIndex();
|
||||
}
|
||||
|
||||
/** 从 `attach_cache` 把"下好了的"回填进内存 Map(并补解密缺失的明文临时文件) */
|
||||
private async rebuildIndex(): Promise<void> {
|
||||
this.localMap.clear();
|
||||
const db: FbDb = FbDb.inst();
|
||||
if (!db.isReady()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const rows: FbRow[] = await db.queryRows(
|
||||
"SELECT key, path FROM attach_cache WHERE state = 'done'");
|
||||
const key: Uint8Array | null = FB_KEY_STORE.dek();
|
||||
for (const r of rows) {
|
||||
const k: string = r.strOr('key', '');
|
||||
const p: string = r.strOr('path', '');
|
||||
if (k === '' || p === '') {
|
||||
continue;
|
||||
}
|
||||
const tmpPath: string = this.tmpPath(k);
|
||||
// 明文临时文件可能被系统清了 —— 有密钥就补解一次
|
||||
if (!fileIo.accessSync(tmpPath) && key !== null) {
|
||||
await this.decryptToTmp(k, key);
|
||||
}
|
||||
if (fileIo.accessSync(tmpPath)) {
|
||||
this.localMap.set(k, 'file://' + tmpPath);
|
||||
}
|
||||
}
|
||||
hilog.info(DOMAIN, TAG, '附件索引恢复 %{public}d 条', this.localMap.size);
|
||||
} catch (err) {
|
||||
hilog.warn(DOMAIN, TAG, '恢复附件索引失败:%{public}s', JSON.stringify(err));
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步查:这张图本地有吗('' = 没有,调用方回退用网络地址) */
|
||||
localPath(key: string): string {
|
||||
if (key === '') {
|
||||
return '';
|
||||
}
|
||||
const p: string | undefined = this.localMap.get(key);
|
||||
return p !== undefined ? p : '';
|
||||
}
|
||||
|
||||
/** 已缓存条数(面板显示) */
|
||||
cachedCount(): number {
|
||||
return this.localMap.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一批附件键登记进待下载队列(已有的跳过、失败过的按次数决定要不要再试)。
|
||||
*
|
||||
* @returns 登记后"还需要下载"的条数
|
||||
*/
|
||||
async collectKeys(keys: string[]): Promise<number> {
|
||||
const db: FbDb = FbDb.inst();
|
||||
if (!db.isReady() || keys.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
let added: number = 0;
|
||||
try {
|
||||
for (const k of keys) {
|
||||
if (k === '' || this.localMap.has(k)) {
|
||||
continue;
|
||||
}
|
||||
// `INSERT OR IGNORE`:已有台账的行不动(保住 tries 计数,否则会被重置成永远重试)
|
||||
await db.exec(
|
||||
'INSERT OR IGNORE INTO attach_cache (key, path, size, state, tries, updated_at) '
|
||||
+ "VALUES (?, '', 0, 'pending', 0, ?)", [k, Date.now()]);
|
||||
added += 1;
|
||||
}
|
||||
} catch (err) {
|
||||
hilog.warn(DOMAIN, TAG, '登记附件失败:%{public}s', JSON.stringify(err));
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/**
|
||||
* 跑队列(幂等:重复调用时后一个直接返回)。
|
||||
*
|
||||
* 进度写进 `FbSyncState`(顶栏图标与面板订阅它)。
|
||||
*/
|
||||
async run(): Promise<void> {
|
||||
if (this.running) {
|
||||
return;
|
||||
}
|
||||
this.running = true;
|
||||
const st = fbSyncState();
|
||||
const db: FbDb = FbDb.inst();
|
||||
try {
|
||||
if (!db.isReady()) {
|
||||
return;
|
||||
}
|
||||
const rows: FbRow[] = await db.queryRows(
|
||||
"SELECT key, tries FROM attach_cache WHERE state = 'pending' ORDER BY updated_at ASC");
|
||||
const total: number = rows.length;
|
||||
if (total === 0) {
|
||||
st.attachFinish();
|
||||
return;
|
||||
}
|
||||
// 已缓存字节数(面板上"已缓存 12.3 MB")
|
||||
const bytes0: number = await db.scalar(
|
||||
"SELECT COALESCE(SUM(size), 0) FROM attach_cache WHERE state = 'done'");
|
||||
st.attachBegin(total, bytes0);
|
||||
|
||||
const token: string = FB_TOKEN_STORE.get();
|
||||
const key: Uint8Array | null = FB_KEY_STORE.dek();
|
||||
if (key === null) {
|
||||
// 没有数据密钥就下不了(下了也加密不了)—— 这不是错误,等下次 init 后再说
|
||||
hilog.warn(DOMAIN, TAG, '没有可用的数据密钥,跳过附件下载');
|
||||
st.attachFinish();
|
||||
return;
|
||||
}
|
||||
|
||||
let done: number = 0;
|
||||
let bytes: number = bytes0;
|
||||
for (const r of rows) {
|
||||
const k: string = r.strOr('key', '');
|
||||
const tries: number = r.num('tries', 0);
|
||||
const got: number = await this.fetchOne(k, token, key);
|
||||
if (got > 0) {
|
||||
bytes += got;
|
||||
} else {
|
||||
const nextTries: number = tries + 1;
|
||||
await db.exec(
|
||||
'UPDATE attach_cache SET tries = ?, state = ?, updated_at = ? WHERE key = ?',
|
||||
[nextTries, nextTries >= MAX_TRIES ? 'fail' : 'pending', Date.now(), k]);
|
||||
}
|
||||
done += 1;
|
||||
st.attachStep(done, bytes);
|
||||
}
|
||||
hilog.info(DOMAIN, TAG, '附件队列跑完:%{public}d 条', done);
|
||||
} catch (err) {
|
||||
hilog.warn(DOMAIN, TAG, '附件队列异常:%{public}s', JSON.stringify(err));
|
||||
} finally {
|
||||
this.running = false;
|
||||
st.attachFinish();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载一个附件并落盘(加密 + 解密一份给界面)。
|
||||
*
|
||||
* @returns 写入的字节数(0 = 失败)
|
||||
*/
|
||||
private async fetchOne(key: string, token: string, dek: Uint8Array): Promise<number> {
|
||||
const url: string = fbImageUrl(key, token);
|
||||
if (url === '') {
|
||||
return 0;
|
||||
}
|
||||
const req: http.HttpRequest = http.createHttp();
|
||||
try {
|
||||
const resp: http.HttpResponse = await req.request(url, {
|
||||
method: http.RequestMethod.GET,
|
||||
connectTimeout: 15000,
|
||||
readTimeout: 30000,
|
||||
expectDataType: http.HttpDataType.ARRAY_BUFFER
|
||||
});
|
||||
if (resp.responseCode !== 200) {
|
||||
hilog.warn(DOMAIN, TAG, '附件 %{public}s 下载失败:HTTP %{public}d', key, resp.responseCode);
|
||||
return 0;
|
||||
}
|
||||
const raw: ArrayBuffer | string = resp.result as ArrayBuffer | string;
|
||||
if (typeof raw === 'string') {
|
||||
return 0; // 期望的是二进制;回了字符串说明是错误页
|
||||
}
|
||||
const plain: Uint8Array = new Uint8Array(raw);
|
||||
if (plain.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const enc: Uint8Array | null = await fbAesEncrypt(dek, plain);
|
||||
if (enc === null) {
|
||||
return 0;
|
||||
}
|
||||
const name: string = this.fileName(key);
|
||||
const encPath: string = this.dirEnc + '/' + name + '.enc';
|
||||
this.writeAll(encPath, enc.buffer.slice(enc.byteOffset, enc.byteOffset + enc.byteLength));
|
||||
|
||||
// 给界面的明文临时文件(同一份数据,不重复解密)
|
||||
const tmpPath: string = this.dirTmp + '/' + name + this.extOf(key);
|
||||
this.writeAll(tmpPath, raw);
|
||||
this.localMap.set(key, 'file://' + tmpPath);
|
||||
|
||||
await FbDb.inst().exec(
|
||||
"UPDATE attach_cache SET path = ?, size = ?, state = 'done', tries = 0, updated_at = ? "
|
||||
+ 'WHERE key = ?', [name, plain.length, Date.now(), key]);
|
||||
return plain.length;
|
||||
} catch (err) {
|
||||
const e: BusinessError = err as BusinessError;
|
||||
hilog.warn(DOMAIN, TAG, '附件 %{public}s 下载异常:%{public}s', key, JSON.stringify(e));
|
||||
return 0;
|
||||
} finally {
|
||||
req.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** 用密文重新解出一份明文临时文件 */
|
||||
private async decryptToTmp(key: string, dek: Uint8Array): Promise<boolean> {
|
||||
try {
|
||||
const name: string = this.fileName(key);
|
||||
const encPath: string = this.dirEnc + '/' + name + '.enc';
|
||||
if (!fileIo.accessSync(encPath)) {
|
||||
return false;
|
||||
}
|
||||
const blob: Uint8Array | null = this.readAll(encPath);
|
||||
if (blob === null) {
|
||||
return false;
|
||||
}
|
||||
const plain: Uint8Array | null = await fbAesDecrypt(dek, blob);
|
||||
if (plain === null) {
|
||||
return false;
|
||||
}
|
||||
const tmpPath: string = this.dirTmp + '/' + name + this.extOf(key);
|
||||
this.writeAll(tmpPath, plain.buffer.slice(plain.byteOffset, plain.byteOffset + plain.byteLength));
|
||||
this.localMap.set(key, 'file://' + tmpPath);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **清空全部附件**(被踢 / 退出登录)。
|
||||
*
|
||||
* 顺序:先吊销密钥(这样即使目录没删掉,密文也解不开)→ 再删目录 → 再清表。
|
||||
* ⚠️ 删目录失败不抛:密钥已经吊销,剩下的只是无意义的字节。
|
||||
*/
|
||||
async wipe(ctx: common.Context): Promise<void> {
|
||||
await FB_KEY_STORE.revoke(ctx);
|
||||
this.localMap.clear();
|
||||
try {
|
||||
if (this.dirEnc !== '' && fileIo.accessSync(this.dirEnc)) {
|
||||
this.removeDir(this.dirEnc);
|
||||
}
|
||||
if (this.dirTmp !== '' && fileIo.accessSync(this.dirTmp)) {
|
||||
this.removeDir(this.dirTmp);
|
||||
}
|
||||
} catch (err) {
|
||||
hilog.warn(DOMAIN, TAG, '清附件目录失败:%{public}s', JSON.stringify(err));
|
||||
}
|
||||
try {
|
||||
if (FbDb.inst().isReady()) {
|
||||
await FbDb.inst().exec('DELETE FROM attach_cache');
|
||||
}
|
||||
} catch (err) {
|
||||
// 库可能已经被删了(wipe 的调用方通常先清库)—— 不算问题
|
||||
hilog.info(DOMAIN, TAG, '清附件台账时库不可用(多半已删库):忽略');
|
||||
}
|
||||
this.dirsReady = false;
|
||||
}
|
||||
|
||||
/* ---- 从已经落地的数据里扫附件键 ----
|
||||
🔴 为什么用"递归 + 扩展名白名单"而不是硬编码字段名:
|
||||
附件字段在各模块里叫法不一(poster_key / avatar / photos[] / cover_key …),
|
||||
将来服务端加一个字段就要改一次端上。递归扫 + 白名单是"宽进严出":
|
||||
结构随便变,只要值像文件路径就收 —— 而误收的代价只是多下一个小文件。
|
||||
⚠️ 前提是服务端附件值**确实是相对键**(`posters/xxx.jpg`),不是完整 URL。
|
||||
这条由服务端的 `media.php` 约定保证(见 FbHttp 的说明)。 */
|
||||
collectFromJson(json: string, out: string[]): void {
|
||||
if (json === '') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const root: Object = JSON.parse(json) as Object;
|
||||
this.walk(root, out);
|
||||
} catch (err) {
|
||||
// 非 JSON(理论上不会)—— 忽略
|
||||
}
|
||||
}
|
||||
|
||||
private walk(node: Object, out: string[]): void {
|
||||
if (node === null || node === undefined) {
|
||||
return;
|
||||
}
|
||||
if (typeof node === 'string') {
|
||||
if (this.looksLikeAttach(node)) {
|
||||
out.push(node);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node instanceof Array) {
|
||||
const arr: Object[] = node as Object[];
|
||||
for (const it of arr) {
|
||||
this.walk(it, out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof node === 'object') {
|
||||
const rec: Record<string, Object> = node as Record<string, Object>;
|
||||
const ks: string[] = Object.keys(rec);
|
||||
for (const k of ks) {
|
||||
const v: Object = rec[k];
|
||||
// 字段名本身也提示一下(`poster_key` 的 value 万一是空串就跳过)
|
||||
if (typeof v === 'string' && v !== '' && this.looksLikeAttach(v)) {
|
||||
out.push(v);
|
||||
} else if (typeof v === 'object') {
|
||||
this.walk(v, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 值像不像一个附件相对键:`xxx/yyy.ext` 且扩展名在白名单里 */
|
||||
private looksLikeAttach(s: string): boolean {
|
||||
if (s.length < 5 || s.length > 400) {
|
||||
return false;
|
||||
}
|
||||
if (s.indexOf('/') < 0 || s.startsWith('http') || s.indexOf('://') >= 0) {
|
||||
return false;
|
||||
}
|
||||
const ext: string = this.extOf(s).replace('.', '').toLowerCase();
|
||||
return EXT_WHITELIST.indexOf(ext) >= 0;
|
||||
}
|
||||
|
||||
/* ---- 文件小工具 ---- */
|
||||
|
||||
private extOf(p: string): string {
|
||||
const dot: number = p.lastIndexOf('.');
|
||||
const slash: number = p.lastIndexOf('/');
|
||||
if (dot <= slash) {
|
||||
return '';
|
||||
}
|
||||
const e: string = p.substring(dot);
|
||||
return e.length > 8 ? '' : e;
|
||||
}
|
||||
|
||||
/** key → 稳定文件名(sha256 十六进制),避免路径穿越与特殊字符 */
|
||||
private fileName(key: string): string {
|
||||
try {
|
||||
const md: cryptoFramework.Md = cryptoFramework.createMd('SHA256');
|
||||
const enc: util.TextEncoder = new util.TextEncoder();
|
||||
// ⚠️ update 是异步的,但这里只需要一次,用同步的 digestSync 更省事
|
||||
md.updateSync({ data: enc.encodeInto(key) });
|
||||
const d: cryptoFramework.DataBlob = md.digestSync();
|
||||
let hex: string = '';
|
||||
for (let i = 0; i < d.data.length; i++) {
|
||||
hex += ('0' + d.data[i].toString(16)).slice(-2);
|
||||
}
|
||||
return hex;
|
||||
} catch (err) {
|
||||
// 兜底:把不安全字符换掉(不会路径穿越,因为连 `/` 也换掉了)
|
||||
return key.replace(/[^a-zA-Z0-9._-]/g, '_').substring(0, 80);
|
||||
}
|
||||
}
|
||||
|
||||
private tmpPath(key: string): string {
|
||||
return this.dirTmp + '/' + this.fileName(key) + this.extOf(key);
|
||||
}
|
||||
|
||||
private writeAll(path: string, buf: ArrayBuffer): boolean {
|
||||
try {
|
||||
const f: fileIo.File = fileIo.openSync(path,
|
||||
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
|
||||
try {
|
||||
fileIo.writeSync(f.fd, buf);
|
||||
} finally {
|
||||
fileIo.closeSync(f);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
hilog.warn(DOMAIN, TAG, '写文件失败 %{public}s:%{public}s', path, JSON.stringify(err));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private readAll(path: string): Uint8Array | null {
|
||||
try {
|
||||
const f: fileIo.File = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
|
||||
try {
|
||||
const size: number = fileIo.statSync(f.fd).size;
|
||||
if (size <= 0) {
|
||||
return null;
|
||||
}
|
||||
const buf: ArrayBuffer = new ArrayBuffer(size);
|
||||
const n: number = fileIo.readSync(f.fd, buf);
|
||||
return new Uint8Array(buf.slice(0, n));
|
||||
} finally {
|
||||
fileIo.closeSync(f);
|
||||
}
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 删目录(没有递归删除的系统调用,自己走一层) */
|
||||
private removeDir(dir: string): void {
|
||||
try {
|
||||
const names: string[] = fileIo.listFileSync(dir);
|
||||
for (const n of names) {
|
||||
try {
|
||||
fileIo.unlinkSync(dir + '/' + n);
|
||||
} catch (err) {
|
||||
// 单个文件删不掉就跳过
|
||||
}
|
||||
}
|
||||
fileIo.rmdirSync(dir);
|
||||
} catch (err) {
|
||||
hilog.warn(DOMAIN, TAG, '删目录失败 %{public}s:%{public}s', dir, JSON.stringify(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局单例 */
|
||||
export const FB_ATTACH: FbAttachQueueImpl = new FbAttachQueueImpl();
|
||||
|
||||
/**
|
||||
* 🔴 v59:**图片显示的统一入口** —— 本地有就用本地文件,没有才走网络。
|
||||
*
|
||||
* 为什么要包这么一层(而不是让每个页面自己判断):
|
||||
* `Image()` 在 build 里拿地址,一旦"本地/网络"的判断分散到 6 个调用点,
|
||||
* 只要有一处漏了,那张图就永远走网络 —— 而附件同步的全部价值就在"离线能看"。
|
||||
* 收成一个函数,漏不掉。
|
||||
*
|
||||
* ⚠️ 为什么放在这个文件而不是 `FbHttp`:
|
||||
* `FbAttachQueue` 已经 import 了 `fbImageUrl`;把 `fbImageSrc` 放回 `FbHttp`
|
||||
* 就会变成两个文件互相 import(循环依赖,ArkTS 下表现为运行期拿到 undefined)。
|
||||
*
|
||||
* @param key 服务端附件相对键
|
||||
* @param token API 令牌(只有走网络时才用得到)
|
||||
*/
|
||||
export function fbImageSrc(key: string, token: string): string {
|
||||
const local: string = FB_ATTACH.localPath(key);
|
||||
if (local !== '') {
|
||||
return local;
|
||||
}
|
||||
return fbImageUrl(key, token);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 对称加解密原语(鸿蒙端,v58)
|
||||
*
|
||||
* 只做一件事:**用一把 32 字节密钥对一段字节做 AES-256-GCM**。
|
||||
* 密钥从哪来、密文存哪、什么时候吊销 —— 全在 `FbKeyStore` 与 `FbAttachQueue`。
|
||||
*
|
||||
* 落盘格式(自己定的,简单且自描述):
|
||||
* `iv(12) | 密文 | tag(16)`
|
||||
* · iv **每次加密都重新随机**(GCM 复用 iv 是致命的,会直接毁掉机密性);
|
||||
* · tag 由 cryptoFramework 附加在 `doFinal` 输出末尾,解密时要**手工切出来**
|
||||
* 塞进 `GcmParamsSpec.authTag`(不切就抛错,这个坑写在这里免得再查一遍)。
|
||||
*
|
||||
* ⚠️ 全部失败返回 `null` 而不是抛异常:调用方(附件队列)对"解不开"的处理是
|
||||
* **删掉这条缓存、回退到网络图**,不需要把异常当成控制流。
|
||||
*/
|
||||
|
||||
import { cryptoFramework } from '@kit.CryptoArchitectureKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
|
||||
const DOMAIN: number = 0x0000;
|
||||
const TAG: string = 'FbCrypto';
|
||||
|
||||
const IV_LEN: number = 12;
|
||||
const GCM_TAG_LEN: number = 16;
|
||||
|
||||
/**
|
||||
* 变换串。
|
||||
*
|
||||
* ⚠️ GCM 本身不需要 padding,但 cryptoFramework 的 `createCipher` **要求**
|
||||
* 串里带一段 padding 算法(`AES256|GCM|PKCS7`),写 `AES256|GCM`
|
||||
* 在部分版本上会直接抛 801。别"优化"掉它。
|
||||
*/
|
||||
const TRANSFORMATION: string = 'AES256|GCM|PKCS7';
|
||||
|
||||
/** 加密:明文 → `iv|ct|tag`(失败返回 null) */
|
||||
export async function fbAesEncrypt(key: Uint8Array, plain: Uint8Array): Promise<Uint8Array | null> {
|
||||
if (key.length !== 32) {
|
||||
hilog.error(DOMAIN, TAG, '密钥长度不对:%{public}d', key.length);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const iv: Uint8Array = fbRandomBytes(IV_LEN);
|
||||
const symKey: cryptoFramework.SymKey = await fbSymKey(key);
|
||||
const cipher: cryptoFramework.Cipher = cryptoFramework.createCipher(TRANSFORMATION);
|
||||
const params: cryptoFramework.GcmParamsSpec = {
|
||||
iv: { data: iv },
|
||||
aad: { data: new Uint8Array(0) },
|
||||
authTag: { data: new Uint8Array(0) }, // 加密时留空,由 doFinal 产出
|
||||
algName: 'GcmParamsSpec'
|
||||
};
|
||||
await cipher.init(cryptoFramework.CryptoMode.ENCRYPT_MODE, symKey, params);
|
||||
const out: cryptoFramework.DataBlob = await cipher.doFinal({ data: plain });
|
||||
|
||||
const blob: Uint8Array = new Uint8Array(IV_LEN + out.data.length);
|
||||
blob.set(iv, 0);
|
||||
blob.set(out.data, IV_LEN);
|
||||
return blob;
|
||||
} catch (err) {
|
||||
hilog.error(DOMAIN, TAG, '加密失败:%{public}s', JSON.stringify(err));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 解密:`iv|ct|tag` → 明文(失败返回 null = 密钥不对 / 数据坏了) */
|
||||
export async function fbAesDecrypt(key: Uint8Array, blob: Uint8Array): Promise<Uint8Array | null> {
|
||||
if (key.length !== 32) {
|
||||
return null;
|
||||
}
|
||||
if (blob.length <= IV_LEN + GCM_TAG_LEN) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const iv: Uint8Array = blob.slice(0, IV_LEN);
|
||||
const body: Uint8Array = blob.slice(IV_LEN);
|
||||
const ct: Uint8Array = body.slice(0, body.length - GCM_TAG_LEN);
|
||||
const tag: Uint8Array = body.slice(body.length - GCM_TAG_LEN);
|
||||
|
||||
const symKey: cryptoFramework.SymKey = await fbSymKey(key);
|
||||
const cipher: cryptoFramework.Cipher = cryptoFramework.createCipher(TRANSFORMATION);
|
||||
const params: cryptoFramework.GcmParamsSpec = {
|
||||
iv: { data: iv },
|
||||
aad: { data: new Uint8Array(0) },
|
||||
authTag: { data: tag },
|
||||
algName: 'GcmParamsSpec'
|
||||
};
|
||||
await cipher.init(cryptoFramework.CryptoMode.DECRYPT_MODE, symKey, params);
|
||||
const out: cryptoFramework.DataBlob = await cipher.doFinal({ data: ct });
|
||||
return out.data;
|
||||
} catch (err) {
|
||||
// 认证失败(tag 不对)走这里 —— 密钥换了、或文件被改过,都是正常情况
|
||||
hilog.warn(DOMAIN, TAG, '解密失败(密钥已更换或文件损坏):%{public}s', JSON.stringify(err));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 一段随机字节 */
|
||||
export function fbRandomBytes(n: number): Uint8Array {
|
||||
const rnd: cryptoFramework.Random = cryptoFramework.createRandom();
|
||||
return rnd.generateRandomSync(n).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 32 字节原始密钥 → `SymKey`。
|
||||
*
|
||||
* ⚠️ 每次都新建 generator 而不是缓存:`convertKey` 的结果是与 generator 绑定的句柄,
|
||||
* 长期持有并不省什么,却容易在"密钥已吊销"之后继续用着旧句柄。
|
||||
*/
|
||||
async function fbSymKey(key: Uint8Array): Promise<cryptoFramework.SymKey> {
|
||||
const gen: cryptoFramework.SymKeyGenerator = cryptoFramework.createSymKeyGenerator('AES256');
|
||||
return await gen.convertKey({ data: key });
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { relationalStore } from '@kit.ArkData';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { FB_DB_VERSION, FB_SCHEMA_SQL, FB_STORE_CONFIG } from './FbSchema';
|
||||
import { FB_DB_NAME, FB_DB_VERSION, FB_SCHEMA_SQL, FB_STORE_CONFIG } from './FbSchema';
|
||||
|
||||
const DOMAIN: number = 0x0000;
|
||||
const TAG: string = 'FbDb';
|
||||
@@ -42,8 +42,20 @@ export class FbDb {
|
||||
private lastError: string = '';
|
||||
/** 打开进度打点(走到哪一步了,同样为了诊断) */
|
||||
private lastStage: string = '';
|
||||
/**
|
||||
* 🔴 v58:是否**降级成了明文库**(true = 加密没起来)。
|
||||
*
|
||||
* ⚠️ 为什么要单独一个字段,而不是看 `lastError` 里有没有"降级"两个字:
|
||||
* `doOpen` 成功时会清空 `lastError`(那是"这次打开失败没有"的语义),
|
||||
* 拿它当"加密与否"的判据会在成功路径上立刻变成误判。
|
||||
* ⇒ 两个语义分开存。
|
||||
*/
|
||||
private degraded: boolean = false;
|
||||
private degradedReason: string = '';
|
||||
/** 打开中的 Promise(并发去重,见文件头说明) */
|
||||
private openPromise: Promise<relationalStore.RdbStore> | null = null;
|
||||
/** 正在清库的 Promise(`open` 要等它,见 open 里的说明) */
|
||||
private wipePromise: Promise<boolean> | null = null;
|
||||
|
||||
/**
|
||||
* 打开(必要时创建)本地库并保证结构就绪。
|
||||
@@ -52,6 +64,11 @@ export class FbDb {
|
||||
* ⚠️ 不能传 `undefined`——RDB 要靠它定位沙箱目录)
|
||||
*/
|
||||
open(ctx: common.Context): Promise<relationalStore.RdbStore> {
|
||||
// 🔴 v58:正在清库时**不能**打开(会打开一个正在被删除的库,表现为各种诡异错误)。
|
||||
// 等它清完再走一遍 open —— `wipePromise` 在 finally 里清空,所以只会递归一次。
|
||||
if (this.wipePromise !== null) {
|
||||
return this.wipePromise.then((): Promise<relationalStore.RdbStore> => this.open(ctx));
|
||||
}
|
||||
if (this.store !== null) {
|
||||
return Promise.resolve(this.store);
|
||||
}
|
||||
@@ -70,7 +87,7 @@ export class FbDb {
|
||||
// 说明**某一步静默失败了**。分步打点才能定位是哪一步。
|
||||
this.lastStage = '[1] 准备 getRdbStore';
|
||||
hilog.info(DOMAIN, TAG, '[1] 准备 getRdbStore,name=%{public}s', FB_STORE_CONFIG.name);
|
||||
const store: relationalStore.RdbStore = await relationalStore.getRdbStore(ctx, FB_STORE_CONFIG);
|
||||
const store: relationalStore.RdbStore = await this.openWithEncryption(ctx);
|
||||
this.lastStage = '[2] getRdbStore 成功,version=' + store.version.toString();
|
||||
hilog.info(DOMAIN, TAG, '[2] getRdbStore 成功,当前 version=%{public}d', store.version);
|
||||
|
||||
@@ -85,7 +102,10 @@ export class FbDb {
|
||||
|
||||
this.store = store;
|
||||
this.lastStage = '[5] 就绪,version=' + store.version.toString();
|
||||
this.lastError = '';
|
||||
// ⚠️ 降级必须**一直看得见**,不能被"这次打开成功"掩盖掉
|
||||
this.lastError = this.degraded
|
||||
? ('本地库未能加密(已降级为明文):' + this.degradedReason)
|
||||
: '';
|
||||
hilog.info(DOMAIN, TAG, '[5] 本地库就绪,版本=%{public}d', store.version);
|
||||
return store;
|
||||
} catch (err) {
|
||||
@@ -104,6 +124,60 @@ export class FbDb {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 v58:带加密地打开本地库,并把"老明文库"平滑过去。
|
||||
*
|
||||
* 三种情况:
|
||||
* ① 新装 / 已是加密库 → 直接成功。
|
||||
* ② **老用户手里是明文库** → 带 `encrypt:true` 打开必然失败(ArkData 不允许改加密属性),
|
||||
* 此时**删库重建**。敢删是因为本地库只是服务端数据的缓存
|
||||
* (见 `FbSchema` 头部的"本地优先"说明):下一次同步会把数据重新拉回来。
|
||||
* ③ 这台机器根本不支持加密(老系统/内核异常)→ **降级明文打开**,
|
||||
* 并把原因写进 `lastError`(设置页的「诊断」会显示)。
|
||||
*
|
||||
* 🔴 为什么宁可降级也不让用户打不开:
|
||||
* 这首先是一个"看家里日程"的应用。加密是加分项,可用性是底线。
|
||||
* 但也**绝不能静默** —— 降级必须留痕,否则用户会以为自己受着保护。
|
||||
*
|
||||
* ⚠️ 别把 `deleteRdbStore` 当成"安全擦除":闪存上可能还有旧块。
|
||||
* 真正起作用的是"新库是密文 + 密钥在系统密钥库"。
|
||||
*/
|
||||
private async openWithEncryption(ctx: common.Context): Promise<relationalStore.RdbStore> {
|
||||
try {
|
||||
return await relationalStore.getRdbStore(ctx, FB_STORE_CONFIG);
|
||||
} catch (first) {
|
||||
const e1: BusinessError = first as BusinessError;
|
||||
hilog.warn(DOMAIN, TAG, '加密打开失败(可能是老明文库):code=%{public}d msg=%{public}s',
|
||||
e1.code !== undefined ? e1.code : -1,
|
||||
e1.message !== undefined ? e1.message : JSON.stringify(first));
|
||||
|
||||
// ② 删掉旧库,重建一个加密库
|
||||
this.lastStage = '[1b] 旧库不兼容加密,删库重建';
|
||||
try {
|
||||
await relationalStore.deleteRdbStore(ctx, FB_DB_NAME);
|
||||
const store: relationalStore.RdbStore = await relationalStore.getRdbStore(ctx, FB_STORE_CONFIG);
|
||||
this.degraded = false; // 重建成功 = 这次真的加密上了
|
||||
this.degradedReason = '';
|
||||
hilog.info(DOMAIN, TAG, '旧明文库已重建为加密库(数据要靠重新同步补回来)');
|
||||
return store;
|
||||
} catch (second) {
|
||||
// ③ 降级:明文打开,绝不把用户挡在门外
|
||||
const e2: BusinessError = second as BusinessError;
|
||||
hilog.error(DOMAIN, TAG, '加密库建不起来,降级明文:code=%{public}d msg=%{public}s',
|
||||
e2.code !== undefined ? e2.code : -1,
|
||||
e2.message !== undefined ? e2.message : JSON.stringify(second));
|
||||
this.degraded = true;
|
||||
this.degradedReason = e2.message !== undefined ? e2.message : JSON.stringify(second);
|
||||
const plain: relationalStore.StoreConfig = {
|
||||
name: FB_DB_NAME,
|
||||
securityLevel: relationalStore.SecurityLevel.S1,
|
||||
encrypt: false
|
||||
};
|
||||
return await relationalStore.getRdbStore(ctx, plain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 首装建表。
|
||||
*
|
||||
@@ -219,6 +293,25 @@ export class FbDb {
|
||||
hilog.info(DOMAIN, TAG, 'v4 迁移完成(名册电话)');
|
||||
}
|
||||
|
||||
// ---- v4 → v5:增量同步水位 + 附件缓存台账(v58/v59)----
|
||||
// 两张都是新表,用 IF NOT EXISTS 可重复执行。
|
||||
// ⚠️ 老用户升级后**第一次同步必须是全量**:`module_sync` 是空的,
|
||||
// 端上把"没有指纹记录"当成"一定得拉"(见 FbSyncAll 的 skip 判定)。
|
||||
if (from < 5) {
|
||||
await store.executeSql(
|
||||
'CREATE TABLE IF NOT EXISTS module_sync ('
|
||||
+ ' name TEXT PRIMARY KEY, fp TEXT NOT NULL DEFAULT \'\','
|
||||
+ ' synced_at INTEGER NOT NULL DEFAULT 0, full_at INTEGER NOT NULL DEFAULT 0)');
|
||||
await store.executeSql(
|
||||
'CREATE TABLE IF NOT EXISTS attach_cache ('
|
||||
+ ' key TEXT PRIMARY KEY, path TEXT NOT NULL DEFAULT \'\','
|
||||
+ ' size INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL DEFAULT \'pending\','
|
||||
+ ' tries INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0)');
|
||||
await store.executeSql(
|
||||
'CREATE INDEX IF NOT EXISTS idx_attach_state ON attach_cache (state, tries)');
|
||||
hilog.info(DOMAIN, TAG, 'v5 迁移完成(增量水位 + 附件台账)');
|
||||
}
|
||||
|
||||
store.version = FB_DB_VERSION;
|
||||
}
|
||||
|
||||
@@ -347,6 +440,75 @@ export class FbDb {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 v58:**摧毁本地数据**(被踢下线 / 彻底退出登录时调)。
|
||||
*
|
||||
* 为什么被踢要连库一起删(而不是只锁界面):
|
||||
* 用户 2026-10-17 的原话是「退到登录页面之后,这里面所有的资源、
|
||||
* 所有的内容都是不允许看的。目的就是为了保证数据的安全性」。
|
||||
* 只锁界面挡得住"顺手拿起来点两下",挡不住"拷走沙箱"。
|
||||
* 而本地库**本来只是服务端数据的缓存** —— 删了不丢东西,重新登录就全回来了。
|
||||
* ⇒ 锁界面 + 删库 + 清附件 + 删令牌,四件事一起做才叫"读不出"。
|
||||
*
|
||||
* ⚠️ 顺序很重要:**先关句柄再删库**。RDB 在打开状态下 delete 会失败
|
||||
* (Windows/部分版本上是 EBUSY 那类),表现成"删了但还在"。
|
||||
* ⚠️ 失败**不抛**:删不掉也要让用户回到登录页(那才是主诉求)。
|
||||
* 失败原因记进 `lastError`,设置页诊断能看见。
|
||||
*
|
||||
* @returns 是否真的删掉了(false 时调用方不必报错,但值得记一笔)
|
||||
*/
|
||||
async wipe(ctx: common.Context): Promise<boolean> {
|
||||
// 并发去重:两处同时触发(比如"被踢"和"用户自己点了退出")只清一次
|
||||
if (this.wipePromise !== null) {
|
||||
return this.wipePromise;
|
||||
}
|
||||
const p: Promise<boolean> = this.doWipe(ctx);
|
||||
this.wipePromise = p;
|
||||
try {
|
||||
return await p;
|
||||
} finally {
|
||||
this.wipePromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `wipe` 的实际动作(拆出来只为了包并发去重,逻辑见上) */
|
||||
private async doWipe(ctx: common.Context): Promise<boolean> {
|
||||
// ① 先关句柄(必须)
|
||||
try {
|
||||
await this.close();
|
||||
} catch (err) {
|
||||
hilog.warn(DOMAIN, TAG, '清库前关闭失败(继续尝试删除):%{public}s', JSON.stringify(err));
|
||||
}
|
||||
// ② 删库文件(密钥随之失去意义)
|
||||
let ok: boolean = true;
|
||||
try {
|
||||
await relationalStore.deleteRdbStore(ctx, FB_DB_NAME);
|
||||
hilog.info(DOMAIN, TAG, '本地库已删除');
|
||||
} catch (err) {
|
||||
const e: BusinessError = err as BusinessError;
|
||||
ok = false;
|
||||
this.lastError = '清空本地库失败:' + (e.message !== undefined ? e.message : JSON.stringify(err));
|
||||
hilog.error(DOMAIN, TAG, '清空本地库失败:%{public}s', this.lastError);
|
||||
}
|
||||
// ③ 重置状态:下次 open() 会重新建库(新库 = 空的,等重新同步)
|
||||
this.store = null;
|
||||
this.openPromise = null;
|
||||
this.lastStage = ok ? '[wipe] 已清空' : '[wipe] 清空失败';
|
||||
return ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地库现在是不是"加密的"。
|
||||
*
|
||||
* ⚠️ 这是**推断**,不是权威查询:ArkData 没有"这个库加密了吗"的公开接口。
|
||||
* 判据是"打开过程里没有走过降级分支"(`lastError` 里没有降级记录)。
|
||||
* 用途只有一个:设置页的「诊断」如实告诉用户"你的本机数据是加密的/不是"。
|
||||
* 别拿它做安全决策。
|
||||
*/
|
||||
isEncrypted(): boolean {
|
||||
return this.isReady() && !this.degraded;
|
||||
}
|
||||
|
||||
// ---- 单例 ----
|
||||
private static instance: FbDb | null = null;
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ const KEY_NAME: string = 'device_name';
|
||||
* 读 bundle 要走异步 API + 多一个系统依赖,收益不值。
|
||||
* 发版时记得改这一行(设备管理页会显示它,用户能看出"这台还是旧版本")。
|
||||
*/
|
||||
export const FB_APP_VERSION: string = '0.7.0'; // 与 AppScope/app.json5 的 versionName 保持一致
|
||||
export const FB_APP_VERSION: string = '0.8.0'; // 与 AppScope/app.json5 的 versionName 保持一致
|
||||
|
||||
/** 平台描述:`harmonyos 5.0.1`(拿不到系统名就退回 API 版本) */
|
||||
function platformText(): string {
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 本地数据密钥(鸿蒙端,v58)
|
||||
*
|
||||
* 🔴 这里解决的是用户 2026-10-17 定的 L3 那一句:
|
||||
* 「本地加密(HarmonyOS 密钥库 HUKS,踢设备即吊销密钥):数据在但读不出,
|
||||
* 重登后恢复密钥即可用。」
|
||||
*
|
||||
* 做法(两层密钥):
|
||||
* · **KEK**(密钥加密密钥):HUKS 里生成,别名 `fb_kek_v1`。**不可导出**,
|
||||
* 跟着「应用 + 本机」绑定 —— 拷走文件也拿不到它。
|
||||
* · **DEK**(数据密钥):32 字节随机数,真正用来 AES-GCM 加密附件内容。
|
||||
* 它**用 KEK 包裹**后存在 preferences 里(`dek_wrap`)。
|
||||
* ⇒ 删掉 HUKS 里的 KEK("踢设备"/退出登录)→ 包裹体永远解不开 → DEK 没了
|
||||
* → 附件全成乱码。这就是"数据在但读不出"。
|
||||
* ⇒ 重新登录后:**生成一把新 DEK、重新下载附件**。
|
||||
* 为什么不做"用密码从服务端取回原 DEK":那等于让服务端持有一把能解开
|
||||
* 你手机本地数据的密钥 —— 为了省一次重下,反而把风险搬到了服务端。
|
||||
* 本地库/附件本来就只是服务端数据的缓存,重下是正确且便宜的恢复路径。
|
||||
*
|
||||
* ⚠️⚠️ **降级路径是刻意设计的,不是偷懒**:
|
||||
* 如果这台机器上 HUKS 用不了(老系统、KeyStore 异常、被安全策略禁掉),
|
||||
* 我们**不会**让附件功能瘫掉 —— 而是把 DEK **明文**存进 preferences,
|
||||
* 并把 `weak` 置为 true。
|
||||
* 代价:这台机器上"密钥可被拷走",加密只剩一层(RDB 那层还在)。
|
||||
* 收益:用户还能正常看图。
|
||||
* ⚠️ 这个 `weak` **必须在设置页的「诊断」里如实显示**,不能静默降级
|
||||
* —— 静默降级等于骗用户"你的数据是加密的"。
|
||||
*/
|
||||
|
||||
import { huks } from '@kit.UniversalKeystoreKit';
|
||||
import { cryptoFramework } from '@kit.CryptoArchitectureKit';
|
||||
import { preferences } from '@kit.ArkData';
|
||||
import { util } from '@kit.ArkTS';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
|
||||
const DOMAIN: number = 0x0000;
|
||||
const TAG: string = 'FbKeyStore';
|
||||
|
||||
const STORE_NAME: string = 'fb_keys';
|
||||
/** 包裹后的 DEK(base64(iv|ct|tag)) */
|
||||
const KEY_WRAP: string = 'dek_wrap';
|
||||
/** 降级时明文 DEK(base64)。正常路径下**不存在** */
|
||||
const KEY_PLAIN: string = 'dek_plain';
|
||||
/** HUKS 里 KEK 的别名(删它 = 吊销) */
|
||||
const KEK_ALIAS: string = 'fb_kek_v1';
|
||||
|
||||
const DEK_LEN: number = 32;
|
||||
const IV_LEN: number = 12;
|
||||
const TAG_LEN: number = 16;
|
||||
|
||||
class FbKeyStoreImpl {
|
||||
private store: preferences.Preferences | null = null;
|
||||
private dekBytes: Uint8Array | null = null;
|
||||
private weak: boolean = false;
|
||||
private ready: boolean = false;
|
||||
private lastError: string = '';
|
||||
|
||||
/**
|
||||
* KEK 的**固定**属性(算法 / 长度 / 填充 / 分组模式)。
|
||||
*
|
||||
* ⚠️ 这三项 + `PURPOSE` 是 HUKS 生成密钥必须的一整套;而"加/解密会话"也
|
||||
* 需要同一套里的算法与模式。**两处共用这一份,各自再补一个 `PURPOSE`** ——
|
||||
* 不能像最初那样把 `kekGenProps()`(里面已经带了 `ENCRYPT|DECRYPT`)
|
||||
* 直接拿去开会话再 push 一个 `PURPOSE`:**同一个 tag 出现两次**的行为
|
||||
* 在 HUKS 里没有定义(不同版本可能取第一个,也可能直接报错)。
|
||||
*/
|
||||
private kekBaseProps(): huks.HuksParam[] {
|
||||
return [
|
||||
{ tag: huks.HuksTag.HUKS_TAG_ALGORITHM, value: huks.HuksKeyAlg.HUKS_ALG_AES },
|
||||
{ tag: huks.HuksTag.HUKS_TAG_KEY_SIZE, value: huks.HuksKeySize.HUKS_AES_KEY_SIZE_256 },
|
||||
{ tag: huks.HuksTag.HUKS_TAG_PADDING, value: huks.HuksKeyPadding.HUKS_PADDING_NONE },
|
||||
{ tag: huks.HuksTag.HUKS_TAG_BLOCK_MODE, value: huks.HuksCipherMode.HUKS_MODE_GCM }
|
||||
];
|
||||
}
|
||||
|
||||
/** 生成 KEK 用(固定属性 + 加解密两个用途) */
|
||||
private kekGenProps(): huks.HuksParam[] {
|
||||
const props: huks.HuksParam[] = this.kekBaseProps();
|
||||
props.push({
|
||||
tag: huks.HuksTag.HUKS_TAG_PURPOSE,
|
||||
value: huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_ENCRYPT
|
||||
| huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_DECRYPT
|
||||
});
|
||||
return props;
|
||||
}
|
||||
|
||||
/** 开会话用(固定属性 + **单个**用途,绝不让 `PURPOSE` 出现两次) */
|
||||
private kekSessionProps(purpose: number): huks.HuksParam[] {
|
||||
const props: huks.HuksParam[] = this.kekBaseProps();
|
||||
props.push({ tag: huks.HuksTag.HUKS_TAG_PURPOSE, value: purpose });
|
||||
return props;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载 + 拿到可用的 DEK(启动时调一次即可)。
|
||||
*
|
||||
* @returns DEK 是否可用(true = 附件可以正常读写)
|
||||
*/
|
||||
async init(ctx: common.Context): Promise<boolean> {
|
||||
if (this.ready) {
|
||||
return this.dekBytes !== null;
|
||||
}
|
||||
try {
|
||||
this.store = await preferences.getPreferences(ctx, STORE_NAME);
|
||||
} catch (err) {
|
||||
hilog.warn(DOMAIN, TAG, '密钥库预加载失败:%{public}s', JSON.stringify(err));
|
||||
this.ready = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ① 先用降级路径里那份明文 DEK(上次已经降级过,别再试一遍 HUKS 白等)
|
||||
const plainV: preferences.ValueType = await this.store.get(KEY_PLAIN, '');
|
||||
if (typeof plainV === 'string' && plainV !== '') {
|
||||
this.dekBytes = this.b64Decode(plainV);
|
||||
this.weak = true;
|
||||
this.ready = true;
|
||||
hilog.info(DOMAIN, TAG, '沿用上一次的降级密钥(本机不支持 HUKS)');
|
||||
return this.dekBytes !== null;
|
||||
}
|
||||
|
||||
// ② 正常路径:解包
|
||||
await this.loadOrCreate(ctx);
|
||||
this.ready = true;
|
||||
return this.dekBytes !== null;
|
||||
}
|
||||
|
||||
/** 读包裹体 → 解包;没有包裹体就新生成一把 */
|
||||
private async loadOrCreate(ctx: common.Context): Promise<void> {
|
||||
if (this.store === null) {
|
||||
return;
|
||||
}
|
||||
const wrapV: preferences.ValueType = await this.store.get(KEY_WRAP, '');
|
||||
const wrap: string = typeof wrapV === 'string' ? wrapV : '';
|
||||
|
||||
if (wrap !== '') {
|
||||
const dek: Uint8Array | null = await this.unwrap(wrap);
|
||||
if (dek !== null) {
|
||||
this.dekBytes = dek;
|
||||
this.weak = false;
|
||||
hilog.info(DOMAIN, TAG, '本地数据密钥已解包(HUKS 保护)');
|
||||
return;
|
||||
}
|
||||
// ⚠️ 解包失败 = KEK 已被删(被踢/清过数据)⇒ 旧附件**永久读不出**。
|
||||
// 这里不报错,直接换新 DEK:旧缓存会在队列里被判成"无效"并重下。
|
||||
hilog.warn(DOMAIN, TAG, '包裹体解不开(密钥已被吊销),换一把新的数据密钥');
|
||||
}
|
||||
|
||||
// 新 DEK
|
||||
const fresh: Uint8Array | null = this.randomBytes(DEK_LEN);
|
||||
if (fresh === null) {
|
||||
this.lastError = '随机数生成失败';
|
||||
return;
|
||||
}
|
||||
const wrapped: string | null = await this.wrap(fresh);
|
||||
if (wrapped === null) {
|
||||
// ③ 降级:明文存
|
||||
this.weak = true;
|
||||
this.dekBytes = fresh;
|
||||
this.lastError = 'HUKS 不可用,密钥以明文保存(已降级)';
|
||||
await this.store.put(KEY_PLAIN, this.b64Encode(fresh));
|
||||
await this.store.delete(KEY_WRAP);
|
||||
await this.store.flush();
|
||||
hilog.warn(DOMAIN, TAG, '本地数据密钥降级为明文保存:%{public}s', this.lastError);
|
||||
return;
|
||||
}
|
||||
this.weak = false;
|
||||
this.dekBytes = fresh;
|
||||
await this.store.put(KEY_WRAP, wrapped);
|
||||
await this.store.delete(KEY_PLAIN);
|
||||
await this.store.flush();
|
||||
hilog.info(DOMAIN, TAG, '新数据密钥已生成并用 HUKS 包裹');
|
||||
}
|
||||
|
||||
/** 用 HUKS 的 KEK 加密 DEK,返回 base64(iv|ct|tag);失败返回 null(→ 降级) */
|
||||
private async wrap(dek: Uint8Array): Promise<string | null> {
|
||||
try {
|
||||
const exists: boolean = await huks.isKeyItemExist(KEK_ALIAS, { properties: [] });
|
||||
if (!exists) {
|
||||
await huks.generateKeyItem(KEK_ALIAS, { properties: this.kekGenProps() });
|
||||
hilog.info(DOMAIN, TAG, 'HUKS 里已生成 KEK(不可导出)');
|
||||
}
|
||||
const iv: Uint8Array | null = this.randomBytes(IV_LEN);
|
||||
if (iv === null) {
|
||||
return null;
|
||||
}
|
||||
const props: huks.HuksParam[] =
|
||||
this.kekSessionProps(huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_ENCRYPT);
|
||||
props.push({ tag: huks.HuksTag.HUKS_TAG_NONCE, value: iv });
|
||||
const opt: huks.HuksOptions = { properties: props, inData: dek };
|
||||
const handle: huks.HuksSessionHandle = await huks.initSession(KEK_ALIAS, opt);
|
||||
const ret: huks.HuksReturnResult = await huks.finishSession(handle.handle, opt);
|
||||
const out: Uint8Array | undefined = ret.outData;
|
||||
if (out === undefined || out.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// 落盘格式:iv(12) | 密文+tag
|
||||
const blob: Uint8Array = new Uint8Array(IV_LEN + out.length);
|
||||
blob.set(iv, 0);
|
||||
blob.set(out, IV_LEN);
|
||||
return this.b64Encode(blob);
|
||||
} catch (err) {
|
||||
const e: BusinessError = err as BusinessError;
|
||||
hilog.warn(DOMAIN, TAG, 'HUKS 包裹失败:code=%{public}d msg=%{public}s',
|
||||
e.code !== undefined ? e.code : -1,
|
||||
e.message !== undefined ? e.message : JSON.stringify(err));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 解包;失败返回 null(= 密钥已吊销) */
|
||||
private async unwrap(b64: string): Promise<Uint8Array | null> {
|
||||
try {
|
||||
const blob: Uint8Array | null = this.b64Decode(b64);
|
||||
if (blob === null || blob.length <= IV_LEN + TAG_LEN) {
|
||||
return null;
|
||||
}
|
||||
const iv: Uint8Array = blob.slice(0, IV_LEN);
|
||||
const body: Uint8Array = blob.slice(IV_LEN);
|
||||
const ct: Uint8Array = body.slice(0, body.length - TAG_LEN);
|
||||
const tag: Uint8Array = body.slice(body.length - TAG_LEN);
|
||||
|
||||
const props: huks.HuksParam[] =
|
||||
this.kekSessionProps(huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_DECRYPT);
|
||||
props.push({ tag: huks.HuksTag.HUKS_TAG_NONCE, value: iv });
|
||||
props.push({ tag: huks.HuksTag.HUKS_TAG_AE_TAG, value: tag });
|
||||
const opt: huks.HuksOptions = { properties: props, inData: ct };
|
||||
const handle: huks.HuksSessionHandle = await huks.initSession(KEK_ALIAS, opt);
|
||||
const ret: huks.HuksReturnResult = await huks.finishSession(handle.handle, opt);
|
||||
const out: Uint8Array | undefined = ret.outData;
|
||||
return out !== undefined && out.length === DEK_LEN ? out : null;
|
||||
} catch (err) {
|
||||
hilog.warn(DOMAIN, TAG, 'HUKS 解包失败(密钥多半已被吊销):%{public}s', JSON.stringify(err));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前 DEK(null = 还没有 / 已被吊销) */
|
||||
dek(): Uint8Array | null {
|
||||
return this.dekBytes;
|
||||
}
|
||||
|
||||
/** 是否处于"降级"状态(密钥明文保存)—— 设置页诊断要显示 */
|
||||
isWeak(): boolean {
|
||||
return this.weak;
|
||||
}
|
||||
|
||||
/** 诊断用:最近一次失败原因 */
|
||||
errorText(): string {
|
||||
return this.lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **吊销密钥**(被踢下线 / 彻底退出登录时调)。
|
||||
*
|
||||
* 删 HUKS 里的 KEK + 删包裹体 + 清内存 → 已经落盘的附件密文**再也解不开**
|
||||
* (不是"暂时不给看",是数学上解不开)。这是用户要的"踢设备即吊销密钥"。
|
||||
*
|
||||
* ⚠️ 与"清库"的先后顺序不重要,但**两件都要做**:
|
||||
* 只吊销密钥不清库 = 库里还有明文 JSON 缓存;只清库不吊销 = 附件密文还在。
|
||||
*/
|
||||
async revoke(ctx: common.Context): Promise<void> {
|
||||
this.dekBytes = null;
|
||||
this.weak = false;
|
||||
try {
|
||||
await huks.deleteKeyItem(KEK_ALIAS, { properties: [] });
|
||||
hilog.info(DOMAIN, TAG, 'HUKS 里的 KEK 已删除');
|
||||
} catch (err) {
|
||||
// 本来就不存在也会走到这里 —— 不算错误
|
||||
hilog.info(DOMAIN, TAG, '删除 KEK 时返回(可能本来就不存在):%{public}s', JSON.stringify(err));
|
||||
}
|
||||
try {
|
||||
if (this.store === null) {
|
||||
this.store = await preferences.getPreferences(ctx, STORE_NAME);
|
||||
}
|
||||
await this.store.delete(KEY_WRAP);
|
||||
await this.store.delete(KEY_PLAIN);
|
||||
await this.store.flush();
|
||||
} catch (err) {
|
||||
hilog.warn(DOMAIN, TAG, '清除包裹体失败:%{public}s', JSON.stringify(err));
|
||||
}
|
||||
// 吊销之后重新初始化:下一次用附件时会生成一把全新的 DEK(旧缓存作废)
|
||||
this.ready = false;
|
||||
}
|
||||
|
||||
/* ---- 小工具 ---- */
|
||||
|
||||
private randomBytes(n: number): Uint8Array | null {
|
||||
try {
|
||||
const rnd: cryptoFramework.Random = cryptoFramework.createRandom();
|
||||
const blob: cryptoFramework.DataBlob = rnd.generateRandomSync(n);
|
||||
return blob.data;
|
||||
} catch (err) {
|
||||
hilog.error(DOMAIN, TAG, '随机数失败:%{public}s', JSON.stringify(err));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private b64Encode(b: Uint8Array): string {
|
||||
return new util.Base64Helper().encodeToStringSync(b);
|
||||
}
|
||||
|
||||
private b64Decode(s: string): Uint8Array | null {
|
||||
try {
|
||||
return new util.Base64Helper().decodeSync(s);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局单例 */
|
||||
export const FB_KEY_STORE: FbKeyStoreImpl = new FbKeyStoreImpl();
|
||||
@@ -264,6 +264,26 @@ export class FbMovieRepo {
|
||||
' / 人 ' + personRows.length.toString() +
|
||||
' / 同看 ' + attendeeRows.length.toString() + ')…';
|
||||
const store: relationalStore.RdbStore = this.db.require();
|
||||
|
||||
/* 🔴 v59:先清掉上一次的观影数据,再整批写入 —— 这是**删除传播**的实现。
|
||||
*
|
||||
* 之前只做 upsert(`INSERT OR REPLACE`),于是"在网页上删掉的那一场电影"
|
||||
* 在端上永远留着 —— 用户看到的是已经不存在的记录,还以为同步坏了。
|
||||
* 服务端是**物理删除**(没有 `deleted_at`),所以没有墓碑可拉,
|
||||
* 客户端唯一可靠的判断方式就是"这次没返回 = 已被删"。
|
||||
*
|
||||
* ⚠️ 为什么敢在"拉取成功之后、写入之前"清:
|
||||
* 上面所有失败分支都已经 `return` 了 —— 走到这里说明**服务端数据完整到手**。
|
||||
* 拉取失败时绝不会清,用户手上那份旧数据仍然可用。
|
||||
* ⚠️ 为什么不清 `persons`:它是跨模块共用表(名册/观影都写),
|
||||
* 清它等于把名册也清了。观影同看人残留几条无害(名册同步会覆盖)。
|
||||
* ⚠️ 清 `events` 只清 `kind='movie'`:日程模块将来写入的条目不能被观影同步顺手删掉。
|
||||
*/
|
||||
await this.db.exec("DELETE FROM events WHERE kind = 'movie'");
|
||||
await this.db.exec('DELETE FROM movie_details');
|
||||
await this.db.exec('DELETE FROM event_attendees');
|
||||
await this.db.exec('DELETE FROM cinemas');
|
||||
|
||||
await this.insertInBatches(store, 'cinemas', cinemaRows);
|
||||
// ⚠️ 顺序:events 是主表,其余引用它的 id(便于排查,非外键强制)
|
||||
await this.insertInBatches(store, 'events', eventRows);
|
||||
|
||||
@@ -37,7 +37,7 @@ export const FB_DB_NAME: string = 'familybook.db';
|
||||
* 本地库**不能像服务端那样"每次启动都全量 ALTER 一遍"** ——
|
||||
* 那是拿用户的手机当试验田。必须按版本逐步迁移。
|
||||
*/
|
||||
export const FB_DB_VERSION: number = 4;
|
||||
export const FB_DB_VERSION: number = 5;
|
||||
|
||||
/**
|
||||
* 建表语句。
|
||||
@@ -205,6 +205,45 @@ export const FB_SCHEMA_SQL: string[] = [
|
||||
// 同一行只保留最后一次操作(合并重复改动)
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_row ON pending_ops (table_name, row_id)`,
|
||||
|
||||
/**
|
||||
* 🔴 v58:模块级同步水位(增量同步用)。
|
||||
*
|
||||
* 为什么不能复用上面的 `sync_state`:
|
||||
* 那张表记的是"**这张表**上次同步到哪个服务端时间",它服务于"按表增量拉"。
|
||||
* 而 `changes` 接口给的是"**这个模块**的变更指纹" —— 粒度不同(一个模块 = 多张表)。
|
||||
* 硬塞进同一张表会让两种语义互相覆盖,出问题时根本分不清是谁写的。
|
||||
*
|
||||
* `fp` 是服务端指纹的**原样字符串**(`行数:最大id:最大updated_at`)。
|
||||
* 端上**只做相等比较**,绝不解析它 —— 服务端换算法时端上不用改。
|
||||
*/
|
||||
`CREATE TABLE IF NOT EXISTS module_sync (
|
||||
name TEXT PRIMARY KEY,
|
||||
fp TEXT NOT NULL DEFAULT '',
|
||||
synced_at INTEGER NOT NULL DEFAULT 0,
|
||||
full_at INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
|
||||
/**
|
||||
* 🔴 v59:附件缓存台账(海报 / 头像 / 观影照片 / 资源附件)。
|
||||
*
|
||||
* 为什么要有这张表而不是"直接看文件在不在":
|
||||
* · 要记**失败次数**(下载失败要退避重试,不能每次启动都重撞);
|
||||
* · 要记**大小**(面板上显示"已缓存 12.3MB");
|
||||
* · 要能在"踢设备"时**整表清空**(不该靠遍历目录猜哪些是我们下的)。
|
||||
*
|
||||
* `path` 是沙箱内的文件名(不含目录),内容为 AES 密文(见 `FbAttachQueue`)。
|
||||
* ⚠️ `key` 是**服务端相对键**(如 `posters/963dd25f.jpg`),与 `media.php?k=` 一致。
|
||||
*/
|
||||
`CREATE TABLE IF NOT EXISTS attach_cache (
|
||||
key TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL DEFAULT '',
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
tries INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_attach_state ON attach_cache (state, tries)`,
|
||||
|
||||
// ---------- 名册(v2 新增)----------
|
||||
// 🔴 为什么名册需要**自己**的表,而不是直接用 `persons`:
|
||||
// `persons` 是"全家的通讯录底账"(本家 800+ 人);
|
||||
@@ -277,7 +316,7 @@ export const FB_TABLE_GROUPS: FbTableGroup[] = [
|
||||
},
|
||||
{
|
||||
group: '同步元数据(本地专有)',
|
||||
tables: ['sync_state', 'pending_ops']
|
||||
tables: ['sync_state', 'pending_ops', 'module_sync', 'attach_cache']
|
||||
}
|
||||
];
|
||||
|
||||
@@ -291,7 +330,26 @@ export const FB_SYNCED_TABLES: string[] = [
|
||||
export const FB_STORE_CONFIG: relationalStore.StoreConfig = {
|
||||
name: FB_DB_NAME,
|
||||
securityLevel: relationalStore.SecurityLevel.S1,
|
||||
// ⚠️ 不开 encrypt:库内是家庭日程/观影记录这类普通数据;
|
||||
// 真要加密应走"整库加密 + 密钥进 KeyStore",不是在这里开个开关了事。
|
||||
encrypt: false
|
||||
/**
|
||||
* 🔴 v58(用户 2026-10-17 定 L3:**数据在但读不出**)—— 打开整库加密。
|
||||
*
|
||||
* 为什么这里开个开关就够了(而不像注释里原来写的那样"应该走 KeyStore"):
|
||||
* 鸿蒙 RDB 的 `encrypt: true` **本身就走系统密钥库** ——
|
||||
* 密钥由系统按「应用 + 设备」生成并托管在 HUKS 里,应用拿不到、也导不出明文。
|
||||
* 所以:库文件被拷走(root / 拆机 / 备份包)**没有那把密钥就是一堆密文**。
|
||||
* 这正是用户要的"捡到手机也读不出"。
|
||||
*
|
||||
* ⚠️⚠️ **加密属性一经创建不可修改**(ArkData 的硬约束):
|
||||
* 已装用户手里的是**明文库**,带 `encrypt:true` 打开会直接失败。
|
||||
* 处理办法在 `FbDb.doOpen()`:失败 → 删掉旧库 → 用加密参数重建 → 重新全量同步。
|
||||
* 敢删是因为**本地库只是服务端数据的缓存**(见本文件头"本地优先"那段),
|
||||
* 用户真正的东西都在服务器上;删除只会让下一次同步变慢,不会丢数据。
|
||||
* ⚠️ 但它**不等于安全擦除**:闪存上的旧块可能还有残留。真正的保证来自"新库是密文",
|
||||
* 不要把 `deleteRdbStore` 说成"数据已被彻底销毁"。
|
||||
*
|
||||
* ⚠️ 万一本机不支持加密(老系统/异常),`FbDb` 会**降级用明文打开**并把原因记进
|
||||
* `errorText()` —— 一个家庭应用宁可少一层保护,也不能变成打不开。
|
||||
* 这条降级路径必须在设置页的「诊断」里看得见,不能静默。
|
||||
*/
|
||||
encrypt: true
|
||||
};
|
||||
@@ -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