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
+63 -5
View File
@@ -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
};