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
+530
View File
@@ -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);
}