Files
FamilyBook_harmonyos/entry/src/main/ets/data/FbAttachQueue.ets
T
laoyang 2132ee42d2 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。
2026-10-18 16:01:41 +08:00

531 lines
19 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 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);
}