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,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())!;
|
||||
}
|
||||
Reference in New Issue
Block a user