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
+181
View File
@@ -0,0 +1,181 @@
/**
* 家书 FamilyBook · 同步状态中心(鸿蒙端,v59)
*
* 🔴 为什么单独抽一个 store(而不是仍用 `FbSyncAll` 里那个模块级字符串):
* 用户 2026-10-17 的原话是「应该在右上角的头像和提醒前面加一个同步的进度
* 这样一个按钮或者图示」。要做到这一点,进度必须能被**任何页面**订阅 ——
* 而原来那个 `export let FB_SYNC_ALL_PROGRESS` 只在设置页被轮询读,
* 顶栏根本不知道同步跑没跑、跑到哪。
*
* 🔴🔴 **本类只提供 `@Trace` 字段,不提供"派生 getter"给界面用**。
* 原因:@Builder / build() 里读一个普通 getter **不会建立观察关系**
* (它内部虽然读了 @Trace 字段,但依赖是在 getter 里建立的,不在渲染作用域里),
* 结果就是"底层数据变了、顶栏那块不动"。本项目已在 `@Builder 多值参数`
* 上栽过 6 次(同一类问题),不再重复。
* ⇒ 界面里一律**直接读字段**:`st.phase`、`st.done`、`st.total`。
* 下面那些 `isXxx()` **方法**只给非 UI 逻辑用。
*
* ⚠️ 状态机只有四种 phase,别再加第五种(加一种就要在每个用到的地方补分支):
* idle —— 没在同步,也没有"上次同步失败"没处理
* running —— 正在同步
* done —— 上次同步全部成功
* fail —— 上次同步有失败(面板里给"重试")
*/
import { AppStorageV2 } from '@kit.ArkUI';
export const SYNC_IDLE: string = 'idle';
export const SYNC_RUNNING: string = 'running';
export const SYNC_DONE: string = 'done';
export const SYNC_FAIL: string = 'fail';
@ObservedV2
export class FbSyncState {
/** 见文件头的四种状态 */
@Trace phase: string = SYNC_IDLE;
/** 正在同步的那一步(人话,如「观影」);idle/done 时为空 */
@Trace current: string = '';
/** 已完成步数 / 总步数 */
@Trace done: number = 0;
@Trace total: number = 0;
/**
* 本轮**因为"服务端说没变"而跳过**的模块数。
*
* 🔴 这个数字是"增量同步真的生效了"的唯一证据 ——
* 面板上必须显示它,否则用户看到"2 秒就同步完了"会怀疑没干活。
*/
@Trace skipped: number = 0;
/** 失败模块的中文名(空数组 = 全成功) */
@Trace failed: string[] = [];
/** 上次同步**完成**的时间(毫秒;0 = 从没同步过) */
@Trace lastAt: number = 0;
/** 上次同步结果的人话说明(面板里直接显示) */
@Trace lastNote: string = '';
/** 附件队列:已完成 / 总需下载(0/0 = 没有附件要下) */
@Trace attachDone: number = 0;
@Trace attachTotal: number = 0;
/** 已缓存附件总字节(面板显示"12.3 MB") */
@Trace attachBytes: number = 0;
/** 附件队列是否在跑(与主同步并行,所以要单独一个标志) */
@Trace attachRunning: boolean = false;
/** 致命错误(连 /changes 都打不通之类;'' = 没有) */
@Trace error: string = '';
/* ---- 给非 UI 逻辑用的判定(**不要**在 build/@Builder 里用,见文件头)---- */
isRunning(): boolean {
return this.phase === SYNC_RUNNING;
}
/** 有失败、且当前没在跑 —— 顶栏该显示感叹号 */
hasProblem(): boolean {
return this.phase === SYNC_FAIL && this.failed.length > 0;
}
/** 进度百分比(0~100);total=0 时返回 0 而不是 NaN */
percent(): number {
if (this.total <= 0) {
return 0;
}
const p: number = Math.round(this.done * 100 / this.total);
return p < 0 ? 0 : (p > 100 ? 100 : p);
}
/* ---- 写入口(只有 FbSyncAll / FbAttachQueue 调)---- */
/** 开始一轮同步 */
begin(total: number): void {
this.phase = SYNC_RUNNING;
this.current = '';
this.done = 0;
this.total = total;
this.skipped = 0;
this.failed = [];
this.error = '';
this.lastNote = '';
}
/** 走到某一步(`done` 不含当前这一步) */
step(label: string, done: number, total: number): void {
this.current = label;
this.done = done;
this.total = total;
}
/** 记一次"服务端说没变,跳过" */
skipOne(): void {
this.skipped += 1;
this.done += 1;
}
/** 一轮结束 */
finish(note: string): void {
this.phase = this.failed.length > 0 ? SYNC_FAIL : SYNC_DONE;
this.current = '';
this.lastNote = note;
this.lastAt = Date.now();
}
/** 连"问一句哪些变了"都失败(断网等) */
failAll(msg: string): void {
this.phase = SYNC_FAIL;
this.current = '';
this.error = msg;
this.lastNote = msg;
this.lastAt = Date.now();
}
/** 附件队列开始 */
attachBegin(total: number, bytes: number): void {
this.attachRunning = true;
this.attachTotal = total;
this.attachDone = 0;
this.attachBytes = bytes;
}
/** 附件队列推进一格 */
attachStep(done: number, bytes: number): void {
this.attachDone = done;
this.attachBytes = bytes;
}
/** 附件队列结束(`total` 归零表示"没有遗留") */
attachFinish(): void {
this.attachRunning = false;
}
/** 退出登录 / 被踢时复位(别把上一账号的进度带到下一个账号) */
reset(): void {
this.phase = SYNC_IDLE;
this.current = '';
this.done = 0;
this.total = 0;
this.skipped = 0;
this.failed = [];
this.lastAt = 0;
this.lastNote = '';
this.attachDone = 0;
this.attachTotal = 0;
this.attachBytes = 0;
this.attachRunning = false;
this.error = '';
}
}
/**
* 取同步状态单例。
*
* ⚠️ 用 AppStorageV2(与 `fbAppState()` 同一套路):页面重建不会丢、
* 多个页面拿到的是同一个实例。
*/
export function fbSyncState(): FbSyncState {
return AppStorageV2.connect(FbSyncState, 'fbSyncState', () => new FbSyncState())!;
}