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:
2026-10-18 16:01:41 +08:00
parent 776c95ba0b
commit 2132ee42d2
19 changed files with 2202 additions and 118 deletions
+371 -71
View File
@@ -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);
}