/** * 家书 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=&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 = new Map(); private dirsReady: boolean = false; private running: boolean = false; /** * 建目录 + 恢复内存索引(启动时调一次)。 * * 🔴 为什么要"恢复索引"而不是每次显示时查表: * `Image()` 在 build 里要**同步**拿路径。查库是异步的,来不及。 * ⇒ 内存里存一份 Map,只在 `init` 和下载完成时更新。 */ async init(ctx: common.Context): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 = node as Record; 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); }