From 2132ee42d21b4101f46f5dadcf9967d3e77e4794 Mon Sep 17 00:00:00 2001 From: laoyang Date: Sun, 18 Oct 2026 16:01:41 +0800 Subject: [PATCH] =?UTF-8?q?v0.8.0=20=E6=9C=AC=E5=9C=B0=E5=8A=A0=E5=AF=86?= =?UTF-8?q?=20/=20=E8=A2=AB=E8=B8=A2=E6=B8=85=E6=95=B0=E6=8D=AE=20/=20?= =?UTF-8?q?=E5=A2=9E=E9=87=8F=E5=90=8C=E6=AD=A5=20/=20=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E9=98=9F=E5=88=97=20/=20=E9=A1=B6=E6=A0=8F=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 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。 --- AppScope/app.json5 | 4 +- entry/src/main/ets/common/FbSyncStore.ets | 181 ++++++ .../ets/components/FbMovieDetailSheet.ets | 8 +- .../main/ets/components/FbPersonEditSheet.ets | 5 +- .../src/main/ets/components/FbPersonSheet.ets | 6 +- entry/src/main/ets/components/TopBar.ets | 247 +++++++- entry/src/main/ets/data/FbAttachQueue.ets | 530 ++++++++++++++++++ entry/src/main/ets/data/FbCrypto.ets | 111 ++++ entry/src/main/ets/data/FbDb.ets | 168 +++++- entry/src/main/ets/data/FbDeviceStore.ets | 2 +- entry/src/main/ets/data/FbKeyStore.ets | 314 +++++++++++ entry/src/main/ets/data/FbMovieRepo.ets | 20 + entry/src/main/ets/data/FbSchema.ets | 68 ++- entry/src/main/ets/data/FbSyncAll.ets | 442 ++++++++++++--- .../main/ets/entryability/EntryAbility.ets | 17 + entry/src/main/ets/pages/Index.ets | 112 +++- .../main/ets/pages/movies/FbMoviesPage.ets | 4 +- .../main/ets/pages/people/FbPeoplePage.ets | 4 +- .../ets/pages/settings/FbSettingsPage.ets | 77 ++- 19 files changed, 2202 insertions(+), 118 deletions(-) create mode 100644 entry/src/main/ets/common/FbSyncStore.ets create mode 100644 entry/src/main/ets/data/FbAttachQueue.ets create mode 100644 entry/src/main/ets/data/FbCrypto.ets create mode 100644 entry/src/main/ets/data/FbKeyStore.ets diff --git a/AppScope/app.json5 b/AppScope/app.json5 index 87e3d0f..ae158c0 100644 --- a/AppScope/app.json5 +++ b/AppScope/app.json5 @@ -2,8 +2,8 @@ "app": { "bundleName": "familybook.yangyq.net", "vendor": "yangyq", - "versionCode": 700, - "versionName": "0.7.0", + "versionCode": 800, + "versionName": "0.8.0", "buildVersion": "1", "icon": "$media:fb_logo", "label": "$string:app_name" diff --git a/entry/src/main/ets/common/FbSyncStore.ets b/entry/src/main/ets/common/FbSyncStore.ets new file mode 100644 index 0000000..2cb4ea7 --- /dev/null +++ b/entry/src/main/ets/common/FbSyncStore.ets @@ -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())!; +} diff --git a/entry/src/main/ets/components/FbMovieDetailSheet.ets b/entry/src/main/ets/components/FbMovieDetailSheet.ets index 61e1061..55e0841 100644 --- a/entry/src/main/ets/components/FbMovieDetailSheet.ets +++ b/entry/src/main/ets/components/FbMovieDetailSheet.ets @@ -17,9 +17,9 @@ import { FbColors, FbMetric } from '../common/Theme'; import { FbMovieAttendeeRow, FbMovieDetail } from '../data/FbMovieRepo'; -import { fbImageUrl } from '../data/FbHttp'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { FbAvatar, FbRating } from './Widgets'; +import { fbImageSrc } from '../data/FbAttachQueue'; /** * 照片缩略图的宽高比。 @@ -250,7 +250,7 @@ export struct FbMovieDetailSheet { // ② 大图 // ⚠️ `ImageFit.Contain`:查看器的目的是"看清整张图", // 裁掉边缘就失去意义(缩略图才用 Cover)。 - Image(fbImageUrl(this.viewerList[this.viewerIndex], FB_TOKEN_STORE.get())) + Image(fbImageSrc(this.viewerList[this.viewerIndex], FB_TOKEN_STORE.get())) .width('100%') .height('76%') .objectFit(ImageFit.Contain) @@ -323,7 +323,7 @@ export struct FbMovieDetailSheet { Column({ space: 10 }) { // ---- 海报(大图,2:3)---- if (d.posterKey !== '') { - Image(fbImageUrl(d.posterKey, FB_TOKEN_STORE.get())) + Image(fbImageSrc(d.posterKey, FB_TOKEN_STORE.get())) .width(140) .aspectRatio(0.75) .objectFit(ImageFit.Cover) @@ -462,7 +462,7 @@ export struct FbMovieDetailSheet { // 由 Grid 行高决定)⇒ 竖版照片被 `Cover` 裁掉上下大半, // "看起来格式不对"。用户 2026-10-12 反馈的就是这个。 Column() { - Image(fbImageUrl(k, FB_TOKEN_STORE.get())) + Image(fbImageSrc(k, FB_TOKEN_STORE.get())) .width('100%') .height('100%') .objectFit(ImageFit.Cover) diff --git a/entry/src/main/ets/components/FbPersonEditSheet.ets b/entry/src/main/ets/components/FbPersonEditSheet.ets index 00de747..cf0e599 100644 --- a/entry/src/main/ets/components/FbPersonEditSheet.ets +++ b/entry/src/main/ets/components/FbPersonEditSheet.ets @@ -25,7 +25,7 @@ import { hilog } from '@kit.PerformanceAnalysisKit'; import { FbColors, FbMetric } from '../common/Theme'; import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; -import { FbApiOptions, FbApiResult, fbApi, fbApiBase, fbImageUrl } from '../data/FbHttp'; +import { FbApiOptions, FbApiResult, fbApi, fbApiBase } from '../data/FbHttp'; import { FbAvatar } from './Widgets'; import { http } from '@kit.NetworkKit'; import { fileIo } from '@kit.CoreFileKit'; @@ -33,6 +33,7 @@ import { picker } from '@kit.CoreFileKit'; import { common } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { FbTagPickChip } from './FbTagPickChip'; +import { fbImageSrc } from '../data/FbAttachQueue'; /** * 贴底卡片的手势区缓冲(vp)。 @@ -1057,7 +1058,7 @@ export struct FbPersonEditSheet { // 🔴 v55:**不再自己拼**「基址 + /media.php?...」—— // 服务器地址已经是运行期可配置的(见 FbServerStore), // 自己拼的人漏改一处就会对着旧服务器要图片(表现为"换了服务器头像全裂")。 - return fbImageUrl(this.person.avatar, FB_TOKEN_STORE.get()); + return fbImageSrc(this.person.avatar, FB_TOKEN_STORE.get()); } private idCardHint(): string { diff --git a/entry/src/main/ets/components/FbPersonSheet.ets b/entry/src/main/ets/components/FbPersonSheet.ets index 83ec371..7c790ca 100644 --- a/entry/src/main/ets/components/FbPersonSheet.ets +++ b/entry/src/main/ets/components/FbPersonSheet.ets @@ -27,8 +27,8 @@ import { FbColors, FbMetric } from '../common/Theme'; import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo'; import { FbAvatar, FbTag } from './Widgets'; -import { fbImageUrl } from '../data/FbHttp'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; +import { fbImageSrc } from '../data/FbAttachQueue'; /** * 贴底卡片的手势区缓冲(vp)。 @@ -171,14 +171,14 @@ export struct FbPersonSheet { * `FbAvatar` 的 `imageUrl` 缺省是空串,于是**所有人在详情里都退化成色块字** * (有头像的人也显示不出来)。这是纯粹的漏传参数,不是数据问题。 * ⚠️ `avatar_url`(服务端下发的那个)是**相对路径**,`Image()` 直接用会 - * 打到 App 自己的包名上 ⇒ 必须用 `fbImageUrl(key, token)` 拼成 + * 打到 App 自己的包名上 ⇒ 必须用 `fbImageSrc(key, token)` 拼成 * 带令牌的绝对地址(与列表同一套,见 `fbImageUrl` 的说明)。 */ private avatarUrl(): string { if (this.person === null) { return ''; } - return fbImageUrl(this.person.avatar, FB_TOKEN_STORE.get()); + return fbImageSrc(this.person.avatar, FB_TOKEN_STORE.get()); } /** 拼多行联系方式的显示串(`label: value`,label 空就只显示 value) */ diff --git a/entry/src/main/ets/components/TopBar.ets b/entry/src/main/ets/components/TopBar.ets index dd7582a..f16e9af 100644 --- a/entry/src/main/ets/components/TopBar.ets +++ b/entry/src/main/ets/components/TopBar.ets @@ -18,8 +18,11 @@ import { FbColors, FbIcon, FbMetric } from '../common/Theme'; import { FbAppState, fbAppState } from '../common/Store'; +import { FbSyncState, SYNC_DONE, SYNC_FAIL, SYNC_RUNNING, fbSyncState } from '../common/FbSyncStore'; import { fbModuleOf } from '../model/Modules'; import { FbAvatar } from './Widgets'; +import { syncAll } from '../data/FbSyncAll'; +import { hilog } from '@kit.PerformanceAnalysisKit'; @ComponentV2 export struct FbTopBar { @@ -27,6 +30,17 @@ export struct FbTopBar { /** 右侧额外操作(各模块页自己往里塞「新建」「搜索」等按钮) */ @BuilderParam actions: () => void; + /** + * 同步状态(顶栏图标 + 面板共用)。 + * + * ⚠️ 用 `@Local` 持有单例引用(而不是每处都调 `fbSyncState()`): + * `AppStorageV2.connect` 每次调用都会做一次连接查找,在 build 里反复调用很浪费; + * 而且拿到的本来就是同一个实例。 + */ + @Local sync: FbSyncState = fbSyncState(); + /** 同步面板是否打开 */ + @Local syncSheet: boolean = false; + build() { // 🔴 顶栏横向镜像 —— 「质感握姿」(用户 2026-10-16 第 4 条): // 左手握时家书图标在**左**,右手握时它跑到**右**,方便单手点它开抽屉。 @@ -75,6 +89,52 @@ export struct FbTopBar { // ---- 右:各模块自己的操作 ---- this.actions() + /* 🔴 v59(用户 2026-10-17):「应该在那个右上角的那个头像和提醒前面加一个 + 同步的进度的这样一个按钮或者这样一个图示」。 + ⇒ 就放在这里:模块操作之后、铃铛之前。三态一眼可辨: + · 同步中 → 转圈 + · 有失败 → 红色感叹号 + · 其余 → 灰色对勾("已是最新"或"还没同步",点开面板看详情) + ⚠️ 进度/状态**直接读 `this.sync` 的 @Trace 字段**,不要写成 + `this.syncTitle()` 这种方法调用 —— 方法里读 @Trace 不建立观察关系, + 图标会一直停在初始态(本项目在 @Builder 多值参数上栽过 6 次)。 */ + Button({ type: ButtonType.Circle }) { + if (this.sync.phase === SYNC_RUNNING) { + LoadingProgress() + .width(20) + .height(20) + .color(FbColors.brand) + } else if (this.sync.phase === SYNC_FAIL) { + Text('!') + .fontSize(16) + .fontWeight(FontWeight.Bold) + .fontColor(FbColors.red) + } else { + Image(FbIcon.ui('check')) + .width(17) + .height(17) + .renderMode(ImageRenderMode.Template) + .fillColor(FbColors.muted) + } + } + .width(36) + .height(36) + .backgroundColor(Color.Transparent) + .stateEffect(true) + .onClick(() => { + this.syncSheet = true; + }) + .bindSheet(this.syncSheet, this.syncPanel(), { + height: SheetSize.FIT_CONTENT, + dragBar: true, + showClose: false, + backgroundColor: FbColors.surface, + onDisappear: (): void => { + // 点遮罩/下滑关闭时复位,否则下次点不开 + this.syncSheet = false; + } + }) + // 通知铃铛(未读角标) Stack({ alignContent: Alignment.TopEnd }) { Button({ type: ButtonType.Circle }) { @@ -147,4 +207,189 @@ export struct FbTopBar { const m = fbModuleOf(this.app.activeModule); return m !== undefined ? m.name : '家书'; } -} \ No newline at end of file + + /** + * 手动同步(面板里的按钮)。 + * + * ⚠️ 已经在跑就直接返回:`syncAll` 自己不做并发保护, + * 连点两下会并发拉同一批数据(浪费流量、还可能把水位写乱)。 + */ + private runSync(): void { + if (this.sync.isRunning()) { + return; + } + syncAll().catch((err: Object): void => { + hilog.warn(0x0000, 'FamilyBook', '手动同步失败:%{public}s', JSON.stringify(err)); + }); + } + + /** + * 同步面板(点顶栏图标弹出)。 + * + * 🔴 面板要回答用户三个问题:**现在在干什么 / 上次什么时候 / 有没有出问题**。 + * 以及一个他一定会问的:**为什么这次这么快就同步完了** —— + * 所以"跳过 N 个没有变化的模块"必须显式写出来, + * 否则增量同步会被误认为"没同步"。 + * + * ⚠️ 全部用表达式直接读 `this.sync.xxx`,不调用方法(见文件里那两处说明)。 + */ + @Builder + syncPanel() { + Column({ space: 12 }) { + // ---- 标题 + 状态徽标 ---- + Row({ space: 8 }) { + Text('同步') + .fontSize(18) + .fontWeight(FontWeight.Bold) + .fontColor(FbColors.ink) + Blank() + if (this.sync.phase === SYNC_RUNNING) { + Text('同步中') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.surface) + .backgroundColor(FbColors.brand) + .borderRadius(FbMetric.radiusPill) + .padding({ left: 8, right: 8, top: 2, bottom: 2 }) + } else if (this.sync.phase === SYNC_FAIL) { + Text('有失败') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.surface) + .backgroundColor(FbColors.red) + .borderRadius(FbMetric.radiusPill) + .padding({ left: 8, right: 8, top: 2, bottom: 2 }) + } else if (this.sync.phase === SYNC_DONE) { + Text('已是最新') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.green) + .backgroundColor(FbColors.surfaceAlt) + .borderRadius(FbMetric.radiusPill) + .padding({ left: 8, right: 8, top: 2, bottom: 2 }) + } else { + Text('还没同步') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + .backgroundColor(FbColors.surfaceAlt) + .borderRadius(FbMetric.radiusPill) + .padding({ left: 8, right: 8, top: 2, bottom: 2 }) + } + } + .width('100%') + + // ---- 进度条(只在同步中显示)---- + if (this.sync.phase === SYNC_RUNNING) { + Progress({ + value: this.sync.done, + total: this.sync.total > 0 ? this.sync.total : 1, + type: ProgressType.Linear + }) + .width('100%') + .height(4) + .color(FbColors.brand) + Text('正在同步:' + (this.sync.current !== '' ? this.sync.current : '准备中') + + '(' + this.sync.done.toString() + ' / ' + this.sync.total.toString() + ')') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + .width('100%') + } + + // ---- 人话说明(上次结果 / 失败原因)---- + if (this.sync.lastNote !== '') { + Text(this.sync.lastNote) + .fontSize(FbMetric.fontSmall) + .fontColor(this.sync.phase === SYNC_FAIL ? FbColors.red : FbColors.ink) + .width('100%') + } + + // ---- 统计 ---- + Row({ space: 6 }) { + Text('上次同步') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + Text(this.sync.lastAt > 0 ? fbFmtTime(this.sync.lastAt) : '还没同步过') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.ink) + Blank() + // 🔴 增量生效的证据:这次有多少模块"因为没变"而没拉 + Text(this.sync.skipped > 0 + ? (this.sync.skipped.toString() + ' 个模块没有变化') + : '全部检查过') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + } + .width('100%') + + if (this.sync.attachTotal > 0 || this.sync.attachRunning) { + Row({ space: 6 }) { + Text('附件') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + Text(this.sync.attachDone.toString() + ' / ' + this.sync.attachTotal.toString() + + (this.sync.attachRunning ? ',正在下载…' : ',已下载')) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.ink) + Blank() + Text(fbFmtBytes(this.sync.attachBytes)) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + } + .width('100%') + } + + // ---- 失败清单 ---- + if (this.sync.failed.length > 0) { + Column({ space: 4 }) { + ForEach(this.sync.failed, (name: string) => { + Text('· ' + name + ' 没同步成功') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.red) + .width('100%') + }, (name: string) => name) + } + .width('100%') + } + + // ---- 按钮 ---- + Button(this.sync.phase === SYNC_RUNNING ? '正在同步…' : '立即同步') + .width('100%') + .height(42) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.surface) + .backgroundColor(this.sync.phase === SYNC_RUNNING ? FbColors.faint : FbColors.brand) + .borderRadius(FbMetric.radiusS) + .enabled(this.sync.phase !== SYNC_RUNNING) + .onClick(() => { + this.runSync(); + }) + } + .width('100%') + .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 4, bottom: 20 }) + .alignItems(HorizontalAlign.Start) + } +} + +/** + * 毫秒时间戳 → `MM-DD HH:mm`。 + * + * ⚠️ 为什么不显示年份:面板上只有"上次同步"这一处, + * 一眼要看的是"多久以前",不是"哪一年"。 + */ +function fbFmtTime(ms: number): string { + const d: Date = new Date(ms); + const two = (n: number): string => (n < 10 ? '0' + n.toString() : n.toString()); + return two(d.getMonth() + 1) + '-' + two(d.getDate()) + ' ' + + two(d.getHours()) + ':' + two(d.getMinutes()); +} + +/** 字节 → 人话(`12.3 MB` / `860 KB` / `0 B`) */ +function fbFmtBytes(n: number): string { + if (n <= 0) { + return '0 B'; + } + if (n < 1024) { + return n.toString() + ' B'; + } + if (n < 1024 * 1024) { + return (n / 1024).toFixed(0) + ' KB'; + } + return (n / (1024 * 1024)).toFixed(1) + ' MB'; +} diff --git a/entry/src/main/ets/data/FbAttachQueue.ets b/entry/src/main/ets/data/FbAttachQueue.ets new file mode 100644 index 0000000..db9661d --- /dev/null +++ b/entry/src/main/ets/data/FbAttachQueue.ets @@ -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=&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); +} diff --git a/entry/src/main/ets/data/FbCrypto.ets b/entry/src/main/ets/data/FbCrypto.ets new file mode 100644 index 0000000..68d85bb --- /dev/null +++ b/entry/src/main/ets/data/FbCrypto.ets @@ -0,0 +1,111 @@ +/** + * 家书 FamilyBook · 对称加解密原语(鸿蒙端,v58) + * + * 只做一件事:**用一把 32 字节密钥对一段字节做 AES-256-GCM**。 + * 密钥从哪来、密文存哪、什么时候吊销 —— 全在 `FbKeyStore` 与 `FbAttachQueue`。 + * + * 落盘格式(自己定的,简单且自描述): + * `iv(12) | 密文 | tag(16)` + * · iv **每次加密都重新随机**(GCM 复用 iv 是致命的,会直接毁掉机密性); + * · tag 由 cryptoFramework 附加在 `doFinal` 输出末尾,解密时要**手工切出来** + * 塞进 `GcmParamsSpec.authTag`(不切就抛错,这个坑写在这里免得再查一遍)。 + * + * ⚠️ 全部失败返回 `null` 而不是抛异常:调用方(附件队列)对"解不开"的处理是 + * **删掉这条缓存、回退到网络图**,不需要把异常当成控制流。 + */ + +import { cryptoFramework } from '@kit.CryptoArchitectureKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; + +const DOMAIN: number = 0x0000; +const TAG: string = 'FbCrypto'; + +const IV_LEN: number = 12; +const GCM_TAG_LEN: number = 16; + +/** + * 变换串。 + * + * ⚠️ GCM 本身不需要 padding,但 cryptoFramework 的 `createCipher` **要求** + * 串里带一段 padding 算法(`AES256|GCM|PKCS7`),写 `AES256|GCM` + * 在部分版本上会直接抛 801。别"优化"掉它。 + */ +const TRANSFORMATION: string = 'AES256|GCM|PKCS7'; + +/** 加密:明文 → `iv|ct|tag`(失败返回 null) */ +export async function fbAesEncrypt(key: Uint8Array, plain: Uint8Array): Promise { + if (key.length !== 32) { + hilog.error(DOMAIN, TAG, '密钥长度不对:%{public}d', key.length); + return null; + } + try { + const iv: Uint8Array = fbRandomBytes(IV_LEN); + const symKey: cryptoFramework.SymKey = await fbSymKey(key); + const cipher: cryptoFramework.Cipher = cryptoFramework.createCipher(TRANSFORMATION); + const params: cryptoFramework.GcmParamsSpec = { + iv: { data: iv }, + aad: { data: new Uint8Array(0) }, + authTag: { data: new Uint8Array(0) }, // 加密时留空,由 doFinal 产出 + algName: 'GcmParamsSpec' + }; + await cipher.init(cryptoFramework.CryptoMode.ENCRYPT_MODE, symKey, params); + const out: cryptoFramework.DataBlob = await cipher.doFinal({ data: plain }); + + const blob: Uint8Array = new Uint8Array(IV_LEN + out.data.length); + blob.set(iv, 0); + blob.set(out.data, IV_LEN); + return blob; + } catch (err) { + hilog.error(DOMAIN, TAG, '加密失败:%{public}s', JSON.stringify(err)); + return null; + } +} + +/** 解密:`iv|ct|tag` → 明文(失败返回 null = 密钥不对 / 数据坏了) */ +export async function fbAesDecrypt(key: Uint8Array, blob: Uint8Array): Promise { + if (key.length !== 32) { + return null; + } + if (blob.length <= IV_LEN + GCM_TAG_LEN) { + return null; + } + try { + const iv: Uint8Array = blob.slice(0, IV_LEN); + const body: Uint8Array = blob.slice(IV_LEN); + const ct: Uint8Array = body.slice(0, body.length - GCM_TAG_LEN); + const tag: Uint8Array = body.slice(body.length - GCM_TAG_LEN); + + const symKey: cryptoFramework.SymKey = await fbSymKey(key); + const cipher: cryptoFramework.Cipher = cryptoFramework.createCipher(TRANSFORMATION); + const params: cryptoFramework.GcmParamsSpec = { + iv: { data: iv }, + aad: { data: new Uint8Array(0) }, + authTag: { data: tag }, + algName: 'GcmParamsSpec' + }; + await cipher.init(cryptoFramework.CryptoMode.DECRYPT_MODE, symKey, params); + const out: cryptoFramework.DataBlob = await cipher.doFinal({ data: ct }); + return out.data; + } catch (err) { + // 认证失败(tag 不对)走这里 —— 密钥换了、或文件被改过,都是正常情况 + hilog.warn(DOMAIN, TAG, '解密失败(密钥已更换或文件损坏):%{public}s', JSON.stringify(err)); + return null; + } +} + +/** 一段随机字节 */ +export function fbRandomBytes(n: number): Uint8Array { + const rnd: cryptoFramework.Random = cryptoFramework.createRandom(); + return rnd.generateRandomSync(n).data; +} + +/** + * 32 字节原始密钥 → `SymKey`。 + * + * ⚠️ 每次都新建 generator 而不是缓存:`convertKey` 的结果是与 generator 绑定的句柄, + * 长期持有并不省什么,却容易在"密钥已吊销"之后继续用着旧句柄。 + */ +async function fbSymKey(key: Uint8Array): Promise { + const gen: cryptoFramework.SymKeyGenerator = cryptoFramework.createSymKeyGenerator('AES256'); + return await gen.convertKey({ data: key }); +} diff --git a/entry/src/main/ets/data/FbDb.ets b/entry/src/main/ets/data/FbDb.ets index d41983e..a969e30 100644 --- a/entry/src/main/ets/data/FbDb.ets +++ b/entry/src/main/ets/data/FbDb.ets @@ -15,7 +15,7 @@ import { relationalStore } from '@kit.ArkData'; import { common } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { BusinessError } from '@kit.BasicServicesKit'; -import { FB_DB_VERSION, FB_SCHEMA_SQL, FB_STORE_CONFIG } from './FbSchema'; +import { FB_DB_NAME, FB_DB_VERSION, FB_SCHEMA_SQL, FB_STORE_CONFIG } from './FbSchema'; const DOMAIN: number = 0x0000; const TAG: string = 'FbDb'; @@ -42,8 +42,20 @@ export class FbDb { private lastError: string = ''; /** 打开进度打点(走到哪一步了,同样为了诊断) */ private lastStage: string = ''; + /** + * 🔴 v58:是否**降级成了明文库**(true = 加密没起来)。 + * + * ⚠️ 为什么要单独一个字段,而不是看 `lastError` 里有没有"降级"两个字: + * `doOpen` 成功时会清空 `lastError`(那是"这次打开失败没有"的语义), + * 拿它当"加密与否"的判据会在成功路径上立刻变成误判。 + * ⇒ 两个语义分开存。 + */ + private degraded: boolean = false; + private degradedReason: string = ''; /** 打开中的 Promise(并发去重,见文件头说明) */ private openPromise: Promise | null = null; + /** 正在清库的 Promise(`open` 要等它,见 open 里的说明) */ + private wipePromise: Promise | null = null; /** * 打开(必要时创建)本地库并保证结构就绪。 @@ -52,6 +64,11 @@ export class FbDb { * ⚠️ 不能传 `undefined`——RDB 要靠它定位沙箱目录) */ open(ctx: common.Context): Promise { + // 🔴 v58:正在清库时**不能**打开(会打开一个正在被删除的库,表现为各种诡异错误)。 + // 等它清完再走一遍 open —— `wipePromise` 在 finally 里清空,所以只会递归一次。 + if (this.wipePromise !== null) { + return this.wipePromise.then((): Promise => this.open(ctx)); + } if (this.store !== null) { return Promise.resolve(this.store); } @@ -70,7 +87,7 @@ export class FbDb { // 说明**某一步静默失败了**。分步打点才能定位是哪一步。 this.lastStage = '[1] 准备 getRdbStore'; hilog.info(DOMAIN, TAG, '[1] 准备 getRdbStore,name=%{public}s', FB_STORE_CONFIG.name); - const store: relationalStore.RdbStore = await relationalStore.getRdbStore(ctx, FB_STORE_CONFIG); + const store: relationalStore.RdbStore = await this.openWithEncryption(ctx); this.lastStage = '[2] getRdbStore 成功,version=' + store.version.toString(); hilog.info(DOMAIN, TAG, '[2] getRdbStore 成功,当前 version=%{public}d', store.version); @@ -85,7 +102,10 @@ export class FbDb { this.store = store; this.lastStage = '[5] 就绪,version=' + store.version.toString(); - this.lastError = ''; + // ⚠️ 降级必须**一直看得见**,不能被"这次打开成功"掩盖掉 + this.lastError = this.degraded + ? ('本地库未能加密(已降级为明文):' + this.degradedReason) + : ''; hilog.info(DOMAIN, TAG, '[5] 本地库就绪,版本=%{public}d', store.version); return store; } catch (err) { @@ -104,6 +124,60 @@ export class FbDb { } } + /** + * 🔴 v58:带加密地打开本地库,并把"老明文库"平滑过去。 + * + * 三种情况: + * ① 新装 / 已是加密库 → 直接成功。 + * ② **老用户手里是明文库** → 带 `encrypt:true` 打开必然失败(ArkData 不允许改加密属性), + * 此时**删库重建**。敢删是因为本地库只是服务端数据的缓存 + * (见 `FbSchema` 头部的"本地优先"说明):下一次同步会把数据重新拉回来。 + * ③ 这台机器根本不支持加密(老系统/内核异常)→ **降级明文打开**, + * 并把原因写进 `lastError`(设置页的「诊断」会显示)。 + * + * 🔴 为什么宁可降级也不让用户打不开: + * 这首先是一个"看家里日程"的应用。加密是加分项,可用性是底线。 + * 但也**绝不能静默** —— 降级必须留痕,否则用户会以为自己受着保护。 + * + * ⚠️ 别把 `deleteRdbStore` 当成"安全擦除":闪存上可能还有旧块。 + * 真正起作用的是"新库是密文 + 密钥在系统密钥库"。 + */ + private async openWithEncryption(ctx: common.Context): Promise { + try { + return await relationalStore.getRdbStore(ctx, FB_STORE_CONFIG); + } catch (first) { + const e1: BusinessError = first as BusinessError; + hilog.warn(DOMAIN, TAG, '加密打开失败(可能是老明文库):code=%{public}d msg=%{public}s', + e1.code !== undefined ? e1.code : -1, + e1.message !== undefined ? e1.message : JSON.stringify(first)); + + // ② 删掉旧库,重建一个加密库 + this.lastStage = '[1b] 旧库不兼容加密,删库重建'; + try { + await relationalStore.deleteRdbStore(ctx, FB_DB_NAME); + const store: relationalStore.RdbStore = await relationalStore.getRdbStore(ctx, FB_STORE_CONFIG); + this.degraded = false; // 重建成功 = 这次真的加密上了 + this.degradedReason = ''; + hilog.info(DOMAIN, TAG, '旧明文库已重建为加密库(数据要靠重新同步补回来)'); + return store; + } catch (second) { + // ③ 降级:明文打开,绝不把用户挡在门外 + const e2: BusinessError = second as BusinessError; + hilog.error(DOMAIN, TAG, '加密库建不起来,降级明文:code=%{public}d msg=%{public}s', + e2.code !== undefined ? e2.code : -1, + e2.message !== undefined ? e2.message : JSON.stringify(second)); + this.degraded = true; + this.degradedReason = e2.message !== undefined ? e2.message : JSON.stringify(second); + const plain: relationalStore.StoreConfig = { + name: FB_DB_NAME, + securityLevel: relationalStore.SecurityLevel.S1, + encrypt: false + }; + return await relationalStore.getRdbStore(ctx, plain); + } + } + } + /** * 首装建表。 * @@ -219,6 +293,25 @@ export class FbDb { hilog.info(DOMAIN, TAG, 'v4 迁移完成(名册电话)'); } + // ---- v4 → v5:增量同步水位 + 附件缓存台账(v58/v59)---- + // 两张都是新表,用 IF NOT EXISTS 可重复执行。 + // ⚠️ 老用户升级后**第一次同步必须是全量**:`module_sync` 是空的, + // 端上把"没有指纹记录"当成"一定得拉"(见 FbSyncAll 的 skip 判定)。 + if (from < 5) { + await store.executeSql( + '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)'); + await store.executeSql( + '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)'); + await store.executeSql( + 'CREATE INDEX IF NOT EXISTS idx_attach_state ON attach_cache (state, tries)'); + hilog.info(DOMAIN, TAG, 'v5 迁移完成(增量水位 + 附件台账)'); + } + store.version = FB_DB_VERSION; } @@ -347,6 +440,75 @@ export class FbDb { } } + /** + * 🔴 v58:**摧毁本地数据**(被踢下线 / 彻底退出登录时调)。 + * + * 为什么被踢要连库一起删(而不是只锁界面): + * 用户 2026-10-17 的原话是「退到登录页面之后,这里面所有的资源、 + * 所有的内容都是不允许看的。目的就是为了保证数据的安全性」。 + * 只锁界面挡得住"顺手拿起来点两下",挡不住"拷走沙箱"。 + * 而本地库**本来只是服务端数据的缓存** —— 删了不丢东西,重新登录就全回来了。 + * ⇒ 锁界面 + 删库 + 清附件 + 删令牌,四件事一起做才叫"读不出"。 + * + * ⚠️ 顺序很重要:**先关句柄再删库**。RDB 在打开状态下 delete 会失败 + * (Windows/部分版本上是 EBUSY 那类),表现成"删了但还在"。 + * ⚠️ 失败**不抛**:删不掉也要让用户回到登录页(那才是主诉求)。 + * 失败原因记进 `lastError`,设置页诊断能看见。 + * + * @returns 是否真的删掉了(false 时调用方不必报错,但值得记一笔) + */ + async wipe(ctx: common.Context): Promise { + // 并发去重:两处同时触发(比如"被踢"和"用户自己点了退出")只清一次 + if (this.wipePromise !== null) { + return this.wipePromise; + } + const p: Promise = this.doWipe(ctx); + this.wipePromise = p; + try { + return await p; + } finally { + this.wipePromise = null; + } + } + + /** `wipe` 的实际动作(拆出来只为了包并发去重,逻辑见上) */ + private async doWipe(ctx: common.Context): Promise { + // ① 先关句柄(必须) + try { + await this.close(); + } catch (err) { + hilog.warn(DOMAIN, TAG, '清库前关闭失败(继续尝试删除):%{public}s', JSON.stringify(err)); + } + // ② 删库文件(密钥随之失去意义) + let ok: boolean = true; + try { + await relationalStore.deleteRdbStore(ctx, FB_DB_NAME); + hilog.info(DOMAIN, TAG, '本地库已删除'); + } catch (err) { + const e: BusinessError = err as BusinessError; + ok = false; + this.lastError = '清空本地库失败:' + (e.message !== undefined ? e.message : JSON.stringify(err)); + hilog.error(DOMAIN, TAG, '清空本地库失败:%{public}s', this.lastError); + } + // ③ 重置状态:下次 open() 会重新建库(新库 = 空的,等重新同步) + this.store = null; + this.openPromise = null; + this.lastStage = ok ? '[wipe] 已清空' : '[wipe] 清空失败'; + return ok; + } + + /** + * 本地库现在是不是"加密的"。 + * + * ⚠️ 这是**推断**,不是权威查询:ArkData 没有"这个库加密了吗"的公开接口。 + * 判据是"打开过程里没有走过降级分支"(`lastError` 里没有降级记录)。 + * 用途只有一个:设置页的「诊断」如实告诉用户"你的本机数据是加密的/不是"。 + * 别拿它做安全决策。 + */ + isEncrypted(): boolean { + return this.isReady() && !this.degraded; + } + // ---- 单例 ---- private static instance: FbDb | null = null; diff --git a/entry/src/main/ets/data/FbDeviceStore.ets b/entry/src/main/ets/data/FbDeviceStore.ets index c2ada14..76ce72e 100644 --- a/entry/src/main/ets/data/FbDeviceStore.ets +++ b/entry/src/main/ets/data/FbDeviceStore.ets @@ -40,7 +40,7 @@ const KEY_NAME: string = 'device_name'; * 读 bundle 要走异步 API + 多一个系统依赖,收益不值。 * 发版时记得改这一行(设备管理页会显示它,用户能看出"这台还是旧版本")。 */ -export const FB_APP_VERSION: string = '0.7.0'; // 与 AppScope/app.json5 的 versionName 保持一致 +export const FB_APP_VERSION: string = '0.8.0'; // 与 AppScope/app.json5 的 versionName 保持一致 /** 平台描述:`harmonyos 5.0.1`(拿不到系统名就退回 API 版本) */ function platformText(): string { diff --git a/entry/src/main/ets/data/FbKeyStore.ets b/entry/src/main/ets/data/FbKeyStore.ets new file mode 100644 index 0000000..40ae8be --- /dev/null +++ b/entry/src/main/ets/data/FbKeyStore.ets @@ -0,0 +1,314 @@ +/** + * 家书 FamilyBook · 本地数据密钥(鸿蒙端,v58) + * + * 🔴 这里解决的是用户 2026-10-17 定的 L3 那一句: + * 「本地加密(HarmonyOS 密钥库 HUKS,踢设备即吊销密钥):数据在但读不出, + * 重登后恢复密钥即可用。」 + * + * 做法(两层密钥): + * · **KEK**(密钥加密密钥):HUKS 里生成,别名 `fb_kek_v1`。**不可导出**, + * 跟着「应用 + 本机」绑定 —— 拷走文件也拿不到它。 + * · **DEK**(数据密钥):32 字节随机数,真正用来 AES-GCM 加密附件内容。 + * 它**用 KEK 包裹**后存在 preferences 里(`dek_wrap`)。 + * ⇒ 删掉 HUKS 里的 KEK("踢设备"/退出登录)→ 包裹体永远解不开 → DEK 没了 + * → 附件全成乱码。这就是"数据在但读不出"。 + * ⇒ 重新登录后:**生成一把新 DEK、重新下载附件**。 + * 为什么不做"用密码从服务端取回原 DEK":那等于让服务端持有一把能解开 + * 你手机本地数据的密钥 —— 为了省一次重下,反而把风险搬到了服务端。 + * 本地库/附件本来就只是服务端数据的缓存,重下是正确且便宜的恢复路径。 + * + * ⚠️⚠️ **降级路径是刻意设计的,不是偷懒**: + * 如果这台机器上 HUKS 用不了(老系统、KeyStore 异常、被安全策略禁掉), + * 我们**不会**让附件功能瘫掉 —— 而是把 DEK **明文**存进 preferences, + * 并把 `weak` 置为 true。 + * 代价:这台机器上"密钥可被拷走",加密只剩一层(RDB 那层还在)。 + * 收益:用户还能正常看图。 + * ⚠️ 这个 `weak` **必须在设置页的「诊断」里如实显示**,不能静默降级 + * —— 静默降级等于骗用户"你的数据是加密的"。 + */ + +import { huks } from '@kit.UniversalKeystoreKit'; +import { cryptoFramework } from '@kit.CryptoArchitectureKit'; +import { preferences } from '@kit.ArkData'; +import { util } from '@kit.ArkTS'; +import { common } from '@kit.AbilityKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { BusinessError } from '@kit.BasicServicesKit'; + +const DOMAIN: number = 0x0000; +const TAG: string = 'FbKeyStore'; + +const STORE_NAME: string = 'fb_keys'; +/** 包裹后的 DEK(base64(iv|ct|tag)) */ +const KEY_WRAP: string = 'dek_wrap'; +/** 降级时明文 DEK(base64)。正常路径下**不存在** */ +const KEY_PLAIN: string = 'dek_plain'; +/** HUKS 里 KEK 的别名(删它 = 吊销) */ +const KEK_ALIAS: string = 'fb_kek_v1'; + +const DEK_LEN: number = 32; +const IV_LEN: number = 12; +const TAG_LEN: number = 16; + +class FbKeyStoreImpl { + private store: preferences.Preferences | null = null; + private dekBytes: Uint8Array | null = null; + private weak: boolean = false; + private ready: boolean = false; + private lastError: string = ''; + + /** + * KEK 的**固定**属性(算法 / 长度 / 填充 / 分组模式)。 + * + * ⚠️ 这三项 + `PURPOSE` 是 HUKS 生成密钥必须的一整套;而"加/解密会话"也 + * 需要同一套里的算法与模式。**两处共用这一份,各自再补一个 `PURPOSE`** —— + * 不能像最初那样把 `kekGenProps()`(里面已经带了 `ENCRYPT|DECRYPT`) + * 直接拿去开会话再 push 一个 `PURPOSE`:**同一个 tag 出现两次**的行为 + * 在 HUKS 里没有定义(不同版本可能取第一个,也可能直接报错)。 + */ + private kekBaseProps(): huks.HuksParam[] { + return [ + { tag: huks.HuksTag.HUKS_TAG_ALGORITHM, value: huks.HuksKeyAlg.HUKS_ALG_AES }, + { tag: huks.HuksTag.HUKS_TAG_KEY_SIZE, value: huks.HuksKeySize.HUKS_AES_KEY_SIZE_256 }, + { tag: huks.HuksTag.HUKS_TAG_PADDING, value: huks.HuksKeyPadding.HUKS_PADDING_NONE }, + { tag: huks.HuksTag.HUKS_TAG_BLOCK_MODE, value: huks.HuksCipherMode.HUKS_MODE_GCM } + ]; + } + + /** 生成 KEK 用(固定属性 + 加解密两个用途) */ + private kekGenProps(): huks.HuksParam[] { + const props: huks.HuksParam[] = this.kekBaseProps(); + props.push({ + tag: huks.HuksTag.HUKS_TAG_PURPOSE, + value: huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_ENCRYPT + | huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_DECRYPT + }); + return props; + } + + /** 开会话用(固定属性 + **单个**用途,绝不让 `PURPOSE` 出现两次) */ + private kekSessionProps(purpose: number): huks.HuksParam[] { + const props: huks.HuksParam[] = this.kekBaseProps(); + props.push({ tag: huks.HuksTag.HUKS_TAG_PURPOSE, value: purpose }); + return props; + } + + /** + * 预加载 + 拿到可用的 DEK(启动时调一次即可)。 + * + * @returns DEK 是否可用(true = 附件可以正常读写) + */ + async init(ctx: common.Context): Promise { + if (this.ready) { + return this.dekBytes !== null; + } + try { + this.store = await preferences.getPreferences(ctx, STORE_NAME); + } catch (err) { + hilog.warn(DOMAIN, TAG, '密钥库预加载失败:%{public}s', JSON.stringify(err)); + this.ready = true; + return false; + } + + // ① 先用降级路径里那份明文 DEK(上次已经降级过,别再试一遍 HUKS 白等) + const plainV: preferences.ValueType = await this.store.get(KEY_PLAIN, ''); + if (typeof plainV === 'string' && plainV !== '') { + this.dekBytes = this.b64Decode(plainV); + this.weak = true; + this.ready = true; + hilog.info(DOMAIN, TAG, '沿用上一次的降级密钥(本机不支持 HUKS)'); + return this.dekBytes !== null; + } + + // ② 正常路径:解包 + await this.loadOrCreate(ctx); + this.ready = true; + return this.dekBytes !== null; + } + + /** 读包裹体 → 解包;没有包裹体就新生成一把 */ + private async loadOrCreate(ctx: common.Context): Promise { + if (this.store === null) { + return; + } + const wrapV: preferences.ValueType = await this.store.get(KEY_WRAP, ''); + const wrap: string = typeof wrapV === 'string' ? wrapV : ''; + + if (wrap !== '') { + const dek: Uint8Array | null = await this.unwrap(wrap); + if (dek !== null) { + this.dekBytes = dek; + this.weak = false; + hilog.info(DOMAIN, TAG, '本地数据密钥已解包(HUKS 保护)'); + return; + } + // ⚠️ 解包失败 = KEK 已被删(被踢/清过数据)⇒ 旧附件**永久读不出**。 + // 这里不报错,直接换新 DEK:旧缓存会在队列里被判成"无效"并重下。 + hilog.warn(DOMAIN, TAG, '包裹体解不开(密钥已被吊销),换一把新的数据密钥'); + } + + // 新 DEK + const fresh: Uint8Array | null = this.randomBytes(DEK_LEN); + if (fresh === null) { + this.lastError = '随机数生成失败'; + return; + } + const wrapped: string | null = await this.wrap(fresh); + if (wrapped === null) { + // ③ 降级:明文存 + this.weak = true; + this.dekBytes = fresh; + this.lastError = 'HUKS 不可用,密钥以明文保存(已降级)'; + await this.store.put(KEY_PLAIN, this.b64Encode(fresh)); + await this.store.delete(KEY_WRAP); + await this.store.flush(); + hilog.warn(DOMAIN, TAG, '本地数据密钥降级为明文保存:%{public}s', this.lastError); + return; + } + this.weak = false; + this.dekBytes = fresh; + await this.store.put(KEY_WRAP, wrapped); + await this.store.delete(KEY_PLAIN); + await this.store.flush(); + hilog.info(DOMAIN, TAG, '新数据密钥已生成并用 HUKS 包裹'); + } + + /** 用 HUKS 的 KEK 加密 DEK,返回 base64(iv|ct|tag);失败返回 null(→ 降级) */ + private async wrap(dek: Uint8Array): Promise { + try { + const exists: boolean = await huks.isKeyItemExist(KEK_ALIAS, { properties: [] }); + if (!exists) { + await huks.generateKeyItem(KEK_ALIAS, { properties: this.kekGenProps() }); + hilog.info(DOMAIN, TAG, 'HUKS 里已生成 KEK(不可导出)'); + } + const iv: Uint8Array | null = this.randomBytes(IV_LEN); + if (iv === null) { + return null; + } + const props: huks.HuksParam[] = + this.kekSessionProps(huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_ENCRYPT); + props.push({ tag: huks.HuksTag.HUKS_TAG_NONCE, value: iv }); + const opt: huks.HuksOptions = { properties: props, inData: dek }; + const handle: huks.HuksSessionHandle = await huks.initSession(KEK_ALIAS, opt); + const ret: huks.HuksReturnResult = await huks.finishSession(handle.handle, opt); + const out: Uint8Array | undefined = ret.outData; + if (out === undefined || out.length === 0) { + return null; + } + // 落盘格式:iv(12) | 密文+tag + const blob: Uint8Array = new Uint8Array(IV_LEN + out.length); + blob.set(iv, 0); + blob.set(out, IV_LEN); + return this.b64Encode(blob); + } catch (err) { + const e: BusinessError = err as BusinessError; + hilog.warn(DOMAIN, TAG, 'HUKS 包裹失败:code=%{public}d msg=%{public}s', + e.code !== undefined ? e.code : -1, + e.message !== undefined ? e.message : JSON.stringify(err)); + return null; + } + } + + /** 解包;失败返回 null(= 密钥已吊销) */ + private async unwrap(b64: string): Promise { + try { + const blob: Uint8Array | null = this.b64Decode(b64); + if (blob === null || blob.length <= IV_LEN + TAG_LEN) { + return null; + } + const iv: Uint8Array = blob.slice(0, IV_LEN); + const body: Uint8Array = blob.slice(IV_LEN); + const ct: Uint8Array = body.slice(0, body.length - TAG_LEN); + const tag: Uint8Array = body.slice(body.length - TAG_LEN); + + const props: huks.HuksParam[] = + this.kekSessionProps(huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_DECRYPT); + props.push({ tag: huks.HuksTag.HUKS_TAG_NONCE, value: iv }); + props.push({ tag: huks.HuksTag.HUKS_TAG_AE_TAG, value: tag }); + const opt: huks.HuksOptions = { properties: props, inData: ct }; + const handle: huks.HuksSessionHandle = await huks.initSession(KEK_ALIAS, opt); + const ret: huks.HuksReturnResult = await huks.finishSession(handle.handle, opt); + const out: Uint8Array | undefined = ret.outData; + return out !== undefined && out.length === DEK_LEN ? out : null; + } catch (err) { + hilog.warn(DOMAIN, TAG, 'HUKS 解包失败(密钥多半已被吊销):%{public}s', JSON.stringify(err)); + return null; + } + } + + /** 当前 DEK(null = 还没有 / 已被吊销) */ + dek(): Uint8Array | null { + return this.dekBytes; + } + + /** 是否处于"降级"状态(密钥明文保存)—— 设置页诊断要显示 */ + isWeak(): boolean { + return this.weak; + } + + /** 诊断用:最近一次失败原因 */ + errorText(): string { + return this.lastError; + } + + /** + * 🔴 **吊销密钥**(被踢下线 / 彻底退出登录时调)。 + * + * 删 HUKS 里的 KEK + 删包裹体 + 清内存 → 已经落盘的附件密文**再也解不开** + * (不是"暂时不给看",是数学上解不开)。这是用户要的"踢设备即吊销密钥"。 + * + * ⚠️ 与"清库"的先后顺序不重要,但**两件都要做**: + * 只吊销密钥不清库 = 库里还有明文 JSON 缓存;只清库不吊销 = 附件密文还在。 + */ + async revoke(ctx: common.Context): Promise { + this.dekBytes = null; + this.weak = false; + try { + await huks.deleteKeyItem(KEK_ALIAS, { properties: [] }); + hilog.info(DOMAIN, TAG, 'HUKS 里的 KEK 已删除'); + } catch (err) { + // 本来就不存在也会走到这里 —— 不算错误 + hilog.info(DOMAIN, TAG, '删除 KEK 时返回(可能本来就不存在):%{public}s', JSON.stringify(err)); + } + try { + if (this.store === null) { + this.store = await preferences.getPreferences(ctx, STORE_NAME); + } + await this.store.delete(KEY_WRAP); + await this.store.delete(KEY_PLAIN); + await this.store.flush(); + } catch (err) { + hilog.warn(DOMAIN, TAG, '清除包裹体失败:%{public}s', JSON.stringify(err)); + } + // 吊销之后重新初始化:下一次用附件时会生成一把全新的 DEK(旧缓存作废) + this.ready = false; + } + + /* ---- 小工具 ---- */ + + private randomBytes(n: number): Uint8Array | null { + try { + const rnd: cryptoFramework.Random = cryptoFramework.createRandom(); + const blob: cryptoFramework.DataBlob = rnd.generateRandomSync(n); + return blob.data; + } catch (err) { + hilog.error(DOMAIN, TAG, '随机数失败:%{public}s', JSON.stringify(err)); + return null; + } + } + + private b64Encode(b: Uint8Array): string { + return new util.Base64Helper().encodeToStringSync(b); + } + + private b64Decode(s: string): Uint8Array | null { + try { + return new util.Base64Helper().decodeSync(s); + } catch (err) { + return null; + } + } +} + +/** 全局单例 */ +export const FB_KEY_STORE: FbKeyStoreImpl = new FbKeyStoreImpl(); diff --git a/entry/src/main/ets/data/FbMovieRepo.ets b/entry/src/main/ets/data/FbMovieRepo.ets index 2791914..327f5be 100644 --- a/entry/src/main/ets/data/FbMovieRepo.ets +++ b/entry/src/main/ets/data/FbMovieRepo.ets @@ -264,6 +264,26 @@ export class FbMovieRepo { ' / 人 ' + personRows.length.toString() + ' / 同看 ' + attendeeRows.length.toString() + ')…'; const store: relationalStore.RdbStore = this.db.require(); + + /* 🔴 v59:先清掉上一次的观影数据,再整批写入 —— 这是**删除传播**的实现。 + * + * 之前只做 upsert(`INSERT OR REPLACE`),于是"在网页上删掉的那一场电影" + * 在端上永远留着 —— 用户看到的是已经不存在的记录,还以为同步坏了。 + * 服务端是**物理删除**(没有 `deleted_at`),所以没有墓碑可拉, + * 客户端唯一可靠的判断方式就是"这次没返回 = 已被删"。 + * + * ⚠️ 为什么敢在"拉取成功之后、写入之前"清: + * 上面所有失败分支都已经 `return` 了 —— 走到这里说明**服务端数据完整到手**。 + * 拉取失败时绝不会清,用户手上那份旧数据仍然可用。 + * ⚠️ 为什么不清 `persons`:它是跨模块共用表(名册/观影都写), + * 清它等于把名册也清了。观影同看人残留几条无害(名册同步会覆盖)。 + * ⚠️ 清 `events` 只清 `kind='movie'`:日程模块将来写入的条目不能被观影同步顺手删掉。 + */ + await this.db.exec("DELETE FROM events WHERE kind = 'movie'"); + await this.db.exec('DELETE FROM movie_details'); + await this.db.exec('DELETE FROM event_attendees'); + await this.db.exec('DELETE FROM cinemas'); + await this.insertInBatches(store, 'cinemas', cinemaRows); // ⚠️ 顺序:events 是主表,其余引用它的 id(便于排查,非外键强制) await this.insertInBatches(store, 'events', eventRows); diff --git a/entry/src/main/ets/data/FbSchema.ets b/entry/src/main/ets/data/FbSchema.ets index 1e16c6e..9518b28 100644 --- a/entry/src/main/ets/data/FbSchema.ets +++ b/entry/src/main/ets/data/FbSchema.ets @@ -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 }; \ No newline at end of file diff --git a/entry/src/main/ets/data/FbSyncAll.ets b/entry/src/main/ets/data/FbSyncAll.ets index e653dfe..731bd43 100644 --- a/entry/src/main/ets/data/FbSyncAll.ets +++ b/entry/src/main/ets/data/FbSyncAll.ets @@ -1,36 +1,59 @@ /** - * 家书 FamilyBook · 全量同步(鸿蒙端) - * - * 一次把所有模块都同步一遍。 + * 家书 FamilyBook · 全量 / 增量同步(鸿蒙端) * * 🔴 为什么需要它(用户 2026-10-12:"把同步做成全局的,接进来一个模块, * 就自动同步一个模块"): - * 之前每个页面**自己同步自己**(进入观影页同步观影、进名册页同步名册), - * 结果是"用户必须把每个页面都点一遍才拿到全量数据", - * 而且设置页那个「保存并同步」只同步了观影 —— 一个入口只干一部分活, - * 用户没法理解。 + * 之前每个页面**自己同步自己**,结果是"用户必须把每个页面都点一遍才拿到全量数据", + * 而且设置页那个「保存并同步」只同步了观影 —— 一个入口只干一部分活,用户没法理解。 * ⇒ 加这个"全量同步":**一处触发、全部模块都更新**。 * + * 🔴 v59(用户 2026-10-17)把它升级成**首次全量 / 之后增量**: + * 「登录后立即进入主页面,变同步变更新应用中的数据,后续增量同步也一样」。 + * 做法见下面 `plan()`: + * ① 先打 `GET /api/v1/changes`,服务端给每个模块一个**变更指纹** + * (行数 + 最大 id + 最大 updated_at,见服务端 `changes()` 的说明); + * ② 指纹与本地 `module_sync` 里记的一样 ⇒ **这个模块根本没变,不拉**; + * ③ 不一样 / 本地没记录 ⇒ 拉。 + * ⚠️ 为什么端上要"没记录就拉":老用户从 v58 之前的版本升上来时 `module_sync` 是空的, + * 第一条记录必须靠全量建立 —— 否则会"一个模块都不同步"。 + * + * 🔴 **陈旧上限(`FORCE_FULL_AFTER_MS`)不是补丁,是这套方案的一部分**: + * 服务端有一半的表没有 `updated_at`(`movie_details` 等,见服务端注释), + * "改一张没有 updated_at 的表里已有的一行"**指纹察觉不到**。 + * 所以"上次真正拉取"超过 24 小时就无条件全量一次。 + * ⇒ 最坏情况:改了某个字段,最迟 24 小时后端上能看到。 + * 对"家里看日程/看电影"这类数据,这个延迟是可以接受的; + * 而换来的是"日常打开 App 只拉变了的那一两个模块"。 + * * ⚠️ 它与"页面各自同步"**不冲突**,两者都要有: * · 页面各自同步:保证"刚进这一页就有最新数据"; * · 全量同步:保证"不用点遍每一页"。 - * 两条路的终点是同一个缓存(`module_cache` / 实体表),所以谁先谁后都对。 */ import { hilog } from '@kit.PerformanceAnalysisKit'; import { FbMovieRepo, FbSyncResult } from './FbMovieRepo'; import { FbPersonRepo, FbRosterSyncResult } from './FbPersonRepo'; +import { FbDb, FbRow } from './FbDb'; import { FbModuleLoad, FbModuleRepo } from './FbModuleRepo'; import { FB_MODULE_PATH } from './FbModuleLoad'; import { FB_TOKEN_STORE } from './FbTokenStore'; +import { FbApiOptions, FbApiResult, fbApi } from './FbHttp'; +import { FB_ATTACH } from './FbAttachQueue'; +import { FbSyncState, fbSyncState } from '../common/FbSyncStore'; const DOMAIN: number = 0x0000; const TAG: string = 'FbSyncAll'; +/** + * 陈旧上限:距上次**真正拉取**超过这么久,就无条件全量一次。 + * 理由见文件头"陈旧上限不是补丁"那段。 + */ +const FORCE_FULL_AFTER_MS: number = 24 * 3600 * 1000; + /** 全量同步结果 */ export interface FbSyncAllResult { ok: boolean; - /** 成功同步的模块数 */ + /** 成功同步的模块数(**含**因"没变化"而跳过的) */ done: number; /** 总模块数 */ total: number; @@ -38,17 +61,18 @@ export interface FbSyncAllResult { failed: string[]; /** 当前进度文案(同步中实时更新用) */ note: string; + /** 本次因"服务端说没变"而跳过的模块数(增量生效的证据) */ + skipped: number; } /** - * 全量同步的实时进度(供 UI 显示)。 + * 进度回调(可选)。 * - * ⚠️ 为什么用一个**模块级变量**而不是回调: - * 本机设备 `hilog` 读不出内容(已知限制), - * 同步又可能跑十几秒 —— 页面必须能显示"现在到哪一步了", - * 否则用户以为卡死。用轮询读这个变量的方式最省事(见设置页)。 + * 🔴 为什么还要保留这个参数(v59 已经有 `FbSyncState` 了): + * 登录页是"用户正盯着屏幕等首次同步"的场景,它需要一个**从函数里推出来**的回调; + * 而 `FbSyncState` 是给顶栏/面板这种"订阅制"的地方用的。两者不冲突。 */ -export let FB_SYNC_ALL_PROGRESS: string = ''; +export type FbSyncStepCb = (label: string, done: number, total: number) => void; /** 模块的中文名(进度提示用) */ const MODULE_CN: Record = { @@ -63,42 +87,68 @@ const MODULE_CN: Record = { 'books': '藏书', 'notes': '笔记', 'notifications': '通知', + 'kin': '家谱', 'home': '首页', }; /** - * 进度回调(可选)。 + * 走通用缓存的模块(观影 / 名册有实体表,单独走)。 * - * 🔴 为什么加这个参数,而不是让调用方去轮询 `FB_SYNC_ALL_PROGRESS`: - * 模块级 `export let` 的"实时值"依赖打包器是否保留 live binding —— - * 这在 esmodule / 混淆 / tree-shaking 下**不保证**,一旦被内联成常量, - * 进度条就永远停在初始值(而调用方还以为在正常工作)。 - * ⇒ 登录页这种"用户正盯着屏幕等"的场景,进度必须**从函数里推出来**。 - * - * @param label 正在同步的模块中文名(如「观影」) - * @param done 已完成的模块数(**不含**当前这个) - * @param total 模块总数 + * 🔴 `kin`(家谱)**必须在**这里(v59 补的):用户要的是"把所有数据都同步下来", + * 而家谱页原来只在**打开时**才拉 —— 离线进去是一片空白。 + * 它的数据来源与名册同源(`persons` / `relations` / `nicknames`), + * 所以服务端 `CHANGE_TABLES['kin']` 用的是同一批表:名册一变,家谱跟着重拉。 */ -export type FbSyncStepCb = (label: string, done: number, total: number) => void; +const CACHE_MODULES: string[] = [ + 'calendar', 'tasks', 'days', 'trips', 'ledger', 'resources', 'books', 'notes', + 'notifications', 'kin', +]; + +/** `GET /api/v1/changes` 的响应 */ +interface FbChangesData { + server_time?: string; + /** 模块名 → 指纹(null = 服务端判断不了) */ + modules?: Record; + all?: string; +} + +interface FbChangesEnvelope { + ok?: boolean; + error?: string; + code?: string; + data?: FbChangesData; +} + +/** 本地某个模块已同步到的状态 */ +interface FbModMark { + fp: string; + fullAt: number; +} export async function syncAll(onStep?: FbSyncStepCb): Promise { const token: string = FB_TOKEN_STORE.get(); - const failed: string[] = []; + const st: FbSyncState = fbSyncState(); + if (token === '') { + st.failAll('未登录,无法同步'); const r: FbSyncAllResult = { - ok: false, done: 0, total: 12, failed: [], - note: '未登录,无法同步', + ok: false, done: 0, total: 1 + 1 + CACHE_MODULES.length + 1, + failed: [], note: '未登录,无法同步', skipped: 0, }; - FB_SYNC_ALL_PROGRESS = r.note; return r; } + // 总步数**由模块表算出来**(观影 + 名册 + 缓存模块 + 首页;跳过也占一步,百分比才准)。 + // 🔴 别写死数字:v59 加 `kin` 时写死的 12 就会让进度条最后停在 11/12 + // —— 写死常量的进度和真实的循环次数是两套账,迟早对不上。 + const total: number = 1 + 1 + CACHE_MODULES.length + 1; + st.begin(total); let done: number = 0; - const total: number = 12; + const failed: string[] = []; /** 跑一个模块并记账 */ const step = async (label: string, work: () => Promise): Promise => { - FB_SYNC_ALL_PROGRESS = label; + st.step(label, done, total); if (onStep !== undefined) { onStep(label, done, total); } @@ -108,69 +158,319 @@ export async function syncAll(onStep?: FbSyncStepCb): Promise { done += 1; } else { failed.push(label); + done += 1; } } catch (err) { hilog.warn(DOMAIN, TAG, '同步 %{public}s 失败:%{public}s', label, JSON.stringify(err)); failed.push(label); + done += 1; } }; - // ---- ① 有实体表的两个模块(先做:它们的数据量大、最该先落地)---- - await step(MODULE_CN['movies'], async (): Promise => { - const r: FbSyncResult = await FbMovieRepo.inst().sync(token); - return r.ok; - }); + /** 记一步"服务端说没变,跳过" */ + const skip = (label: string): void => { + st.step(label + '(没变化)', done, total); + if (onStep !== undefined) { + onStep(label, done, total); + } + st.skipOne(); + done += 1; + }; - await step(MODULE_CN['roster'], async (): Promise => { - const r: FbRosterSyncResult = await FbPersonRepo.inst().sync(token); - return r.ok; - }); + // ---- ① 问一句"哪些模块变了" ---- + const marks: Map = await loadMarks(); + const fpResult: FbChangesData | null = await fetchChanges(token); + if (fpResult === null) { + /* `/changes` 没拿到:两种可能,处理方式**必须不同**。 + ① 真断网 → 立刻收工。不能退回全量:12 个模块各超时 15 秒 = 3 分钟白等, + 用户会以为 App 卡死(这正是之前"同步很久"的观感来源)。 + ② 服务端是老版本(没这个接口,回 404)→ 退回"全部拉",功能不能因此断掉。 + 判据:再打一次 `/ping`(它不需要令牌、开销极小)。 + 连 `/ping` 都到不了 ⇒ 断网;`/ping` 有 HTTP 状态码 ⇒ 服务端在,只是没这接口。 */ + if (!(await pingReachable())) { + const msg: string = '连不上服务器,等会儿自动重试'; + st.failAll(msg); + const r: FbSyncAllResult = { + ok: false, done: 0, total: total, failed: [], note: msg, skipped: 0, + }; + return r; + } + hilog.warn(DOMAIN, TAG, '服务端不支持变更指纹,退回全量同步'); + } - // ---- ② 其余模块(走通用缓存)---- - const cacheModules: string[] = [ - 'calendar', 'tasks', 'days', 'trips', 'ledger', 'resources', 'books', 'notes', - 'notifications', 'home', - ]; + /** 该查什么:true = 需要拉 */ + const plan = (name: string): boolean => { + if (fpResult === null) { + return true; // 拿不到指纹 ⇒ 只能全量 + } + const mods: Record = fpResult.modules !== undefined ? fpResult.modules : {}; + const serverFp: string | null = mods[name] !== undefined ? mods[name] : null; + if (serverFp === null || serverFp === '') { + return true; // 服务端判断不了这个模块 ⇒ 老实拉 + } + const mark: FbModMark | undefined = marks.get(name); + if (mark === undefined) { + return true; // 本地没记录(新装/刚升级)⇒ 首次全量 + } + if (Date.now() - mark.fullAt > FORCE_FULL_AFTER_MS) { + return true; // 陈旧上限到了(见文件头) + } + return mark.fp !== serverFp; + }; + + // ---- ② 观影 ---- + let anyFetched: boolean = false; // 有任何模块真拉了 ⇒ 首页可能变 + if (plan('movies')) { + anyFetched = true; + await step(MODULE_CN['movies'], async (): Promise => { + const r: FbSyncResult = await FbMovieRepo.inst().sync(token); + if (r.ok) { + await saveMark('movies', fpOf(fpResult, 'movies')); + } + return r.ok; + }); + } else { + skip(MODULE_CN['movies']); + } + + // ---- ③ 名册 ---- + if (plan('roster')) { + anyFetched = true; + await step(MODULE_CN['roster'], async (): Promise => { + const r: FbRosterSyncResult = await FbPersonRepo.inst().sync(token); + if (r.ok) { + await saveMark('roster', fpOf(fpResult, 'roster')); + } + return r.ok; + }); + } else { + skip(MODULE_CN['roster']); + } + + // ---- ④ 其余 9 个模块(走通用缓存)---- const repo: FbModuleRepo = FbModuleRepo.inst(); - for (const name of cacheModules) { + for (const name of CACHE_MODULES) { const label: string = MODULE_CN[name] !== undefined ? MODULE_CN[name] : name; const path: string = FB_MODULE_PATH[name] !== undefined ? FB_MODULE_PATH[name] : ''; if (path === '') { + skip(label); continue; } + if (!plan(name)) { + skip(label); + continue; + } + anyFetched = true; await step(label, async (): Promise => { - // 这里只关心"有没有成功写进缓存":`load` 的 sink 回调是同步的, - // 用一个 Promise 把它包起来,等到网络那次回来(fromCache=false)才算成功。 - const ok: boolean = await new Promise((resolve: (v: boolean) => void): void => { - let settled: boolean = false; - repo.load(name, path, (r: FbModuleLoad): void => { - if (r.fromCache || settled) { - return; // 缓存那次不算结果 - } - settled = true; - resolve(r.error === ''); - }); - // 兜底超时(网络卡住时不能让全量同步永远挂着) - setTimeout((): void => { - if (!settled) { - settled = true; - resolve(false); - } - }, 20000); - }); + const ok: boolean = await loadOne(repo, name, path); + if (ok) { + await saveMark(name, fpOf(fpResult, name)); + } return ok; }); } + // ---- ⑤ 首页(派生页)---- + /* home 不在服务端的指纹里(它由上面这些模块拼出来)。 + 规则:只要有任何一个模块真拉了,首页就可能变 ⇒ 跟着拉; + 全都没变 ⇒ 首页也不会变 ⇒ 跳过。这样日常打开 App 首页也不会白拉一次。 */ + if (anyFetched) { + const homePath: string = FB_MODULE_PATH['home'] !== undefined ? FB_MODULE_PATH['home'] : ''; + if (homePath === '') { + skip(MODULE_CN['home']); + } else { + await step(MODULE_CN['home'], async (): Promise => { + return await loadOne(repo, 'home', homePath); + }); + } + } else { + skip(MODULE_CN['home']); + } + + // ---- ⑥ 收尾:附件后台队列(不阻塞同步完成)---- + collectAttachKeys().then((): void => { + // ⚠️ 不 await:附件可能几百个,主界面不该等它 + FB_ATTACH.run(); + }).catch((err: Object): void => { + hilog.warn(DOMAIN, TAG, '附件键收集失败:%{public}s', JSON.stringify(err)); + }); + + const skipped: number = st.skipped; + const note: string = failed.length === 0 + ? (skipped > 0 + ? ('已是最新:' + done.toString() + ' 个模块,其中 ' + skipped.toString() + ' 个没有变化') + : ('全部 ' + done.toString() + ' 个模块同步完成')) + : (done.toString() + '/' + total.toString() + ' 完成,失败:' + failed.join('、')); + + st.failed = failed; + st.finish(note); + const result: FbSyncAllResult = { ok: failed.length === 0, done: done, total: total, failed: failed, - note: failed.length === 0 - ? ('全部 ' + done.toString() + ' 个模块同步完成') - : (done.toString() + '/' + total.toString() + ' 完成,失败:' + failed.join('、')), + note: note, + skipped: skipped, }; - FB_SYNC_ALL_PROGRESS = result.note; return result; -} \ No newline at end of file +} + +/* ═══════════════════════════════════════════════════════════════ + 内部工具 + ═══════════════════════════════════════════════════════════════ */ + +/** 拉一个模块的通用缓存(等网络那次回来才算结果) */ +function loadOne(repo: FbModuleRepo, name: string, path: string): Promise { + return new Promise((resolve: (v: boolean) => void): void => { + let settled: boolean = false; + repo.load(name, path, (r: FbModuleLoad): void => { + if (r.fromCache || settled) { + return; // 缓存那次不算结果 + } + settled = true; + resolve(r.error === ''); + }); + // 兜底超时(网络卡住时不能让全量同步永远挂着) + setTimeout((): void => { + if (!settled) { + settled = true; + resolve(false); + } + }, 20000); + }); +} + +/** 读本地 `module_sync` 记录 */ +async function loadMarks(): Promise> { + const out: Map = new Map(); + const db: FbDb = FbDb.inst(); + if (!db.isReady()) { + return out; + } + try { + const rows: FbRow[] = await db.queryRows('SELECT name, fp, full_at FROM module_sync'); + for (const r of rows) { + const m: FbModMark = { fp: r.strOr('fp', ''), fullAt: r.num('full_at', 0) }; + out.set(r.strOr('name', ''), m); + } + } catch (err) { + hilog.warn(DOMAIN, TAG, '读同步水位失败(当作首次全量):%{public}s', JSON.stringify(err)); + } + return out; +} + +/** 写一条模块水位 */ +async function saveMark(name: string, fp: string): Promise { + const db: FbDb = FbDb.inst(); + if (!db.isReady()) { + return; + } + try { + await db.exec( + 'INSERT OR REPLACE INTO module_sync (name, fp, synced_at, full_at) VALUES (?,?,?,?)', + [name, fp, Date.now(), Date.now()]); + } catch (err) { + hilog.warn(DOMAIN, TAG, '写同步水位失败:%{public}s', JSON.stringify(err)); + } +} + +/** 从指纹结果里取某模块的指纹(拿不到返回空串 = 下次还会拉) */ +function fpOf(data: FbChangesData | null, name: string): string { + if (data === null || data.modules === undefined) { + return ''; + } + const v: string | null | undefined = data.modules[name]; + return v !== undefined && v !== null ? v : ''; +} + +/** + * 打 `/changes`。 + * + * @returns 数据(可能是 null:网络不通 / 接口不可用 —— 都由调用方退化为全量) + */ +async function fetchChanges(token: string): Promise { + const opts: FbApiOptions = { method: 'GET', token: token, authProbe: true }; + const r: FbApiResult = await fbApi('/api/v1/changes', opts); + if (!r.ok) { + hilog.info(DOMAIN, TAG, '取变更指纹失败(%{public}s),本轮退化为全量', r.error); + return null; + } + try { + const env: FbChangesEnvelope = JSON.parse(r.data) as FbChangesEnvelope; + return env.data !== undefined ? env.data : null; + } catch (err) { + return null; + } +} + +/** + * 服务端到底通不通(只看"能不能打到一个不需要令牌的接口")。 + * + * ⚠️ 判据是 **HTTP 状态码是否大于 0**,不是"返回 ok": + * 老服务端打 `/changes` 会回 404(有状态码、没这个接口),那属于"服务端在,只是不支持"; + * 真断网时 `fbApi` 给的 status 是 0(它在 catch 里返回 `status: 0`)。 + * 用 `ok` 判会把"老服务端"误判成"断网",直接不同步了。 + */ +async function pingReachable(): Promise { + const opts: FbApiOptions = { method: 'GET', authProbe: true }; + const r: FbApiResult = await fbApi('/api/v1/ping', opts); + return r.status > 0; +} + +/** + * 把"已经落地的数据"里出现的附件键登记进队列。 + * + * 来源三处(都在本地,不额外发请求): + * · `module_cache.payload` —— 9 个模块的完整 JSON(递归扫,见 `FbAttachQueue.collectFromJson`); + * · `movie_details.poster_key` / `ticket_key` —— 海报与票根; + * · `persons.avatar` —— 名册头像。 + */ +async function collectAttachKeys(): Promise { + const db: FbDb = FbDb.inst(); + if (!db.isReady()) { + return; + } + const keys: string[] = []; + try { + const caches: FbRow[] = await db.queryRows('SELECT payload FROM module_cache'); + for (const r of caches) { + FB_ATTACH.collectFromJson(r.strOr('payload', ''), keys); + } + const posters: FbRow[] = await db.queryRows( + "SELECT poster_key, ticket_key FROM movie_details " + + "WHERE (poster_key IS NOT NULL AND poster_key <> '') " + + " OR (ticket_key IS NOT NULL AND ticket_key <> '')"); + for (const r of posters) { + const p: string = r.strOr('poster_key', ''); + const t: string = r.strOr('ticket_key', ''); + if (p !== '') { + keys.push(p); + } + if (t !== '') { + keys.push(t); + } + } + const avatars: FbRow[] = await db.queryRows( + "SELECT avatar FROM persons WHERE avatar IS NOT NULL AND avatar <> ''"); + for (const r of avatars) { + const a: string = r.strOr('avatar', ''); + if (a !== '') { + keys.push(a); + } + } + } catch (err) { + hilog.warn(DOMAIN, TAG, '收集附件键失败:%{public}s', JSON.stringify(err)); + } + // 去重后登记 + const uniq: string[] = []; + const seen: Set = new Set(); + for (const k of keys) { + if (!seen.has(k)) { + seen.add(k); + uniq.push(k); + } + } + const n: number = await FB_ATTACH.collectKeys(uniq); + hilog.info(DOMAIN, TAG, '发现附件 %{public}d 个(新登记 %{public}d)', uniq.length, n); +} diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index cfe941d..f56c58b 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -10,6 +10,8 @@ import { FB_SESSION } from '../data/FbSession'; import { FB_CAL_SETTINGS } from '../data/FbCalSettings'; import { FbDb } from '../data/FbDb'; import { FB_MODULE_TOKEN } from '../data/FbModuleRepo'; +import { FB_KEY_STORE } from '../data/FbKeyStore'; +import { FB_ATTACH } from '../data/FbAttachQueue'; const DOMAIN: number = 0x0000; @@ -77,6 +79,21 @@ export default class EntryAbility extends UIAbility { FbDb.inst().open(this.context).catch((err: BusinessError): void => { hilog.warn(DOMAIN, 'FamilyBook', '本地库打开失败:%{public}s', JSON.stringify(err)); }); + + /* 🔴 v58/v59:本地数据密钥 + 附件缓存。 + ① `FB_KEY_STORE.init` —— 解包(或首次生成)附件加密用的数据密钥。 + 它不依赖本地库,可以并行准备。 + ② `FB_ATTACH.init` —— 建目录、把"已下好的附件"回填进内存索引。 + ⚠️ 必须**等库开了**再做:它要读 `attach_cache` 表; + 这里不去 await 库,而是靠 `init` 内部 `isReady()` 判空后自然跳过, + 页面侧第一次用附件时会再补一次(见 `FbAttachQueue.init` 的幂等性)。 + ⚠️ 两者都**不能挡住启动**:附件是"后台慢慢下"的东西, + 让它拖慢首帧就本末倒置了。 */ + FB_KEY_STORE.init(this.context).then((): Promise => { + return FB_ATTACH.init(this.context); + }).catch((err: BusinessError): void => { + hilog.warn(DOMAIN, 'FamilyBook', '附件/密钥初始化失败:%{public}s', JSON.stringify(err)); + }); } onDestroy(): void { diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index e95c17d..2c4c2a0 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -38,6 +38,10 @@ import { FB_SERVER_STORE } from '../data/FbServerStore'; import { FB_DEVICE_STORE } from '../data/FbDeviceStore'; import { FB_SESSION, SESSION_NEED_LOGIN, SESSION_OFFLINE } from '../data/FbSession'; import { fbOnUnauthorized } from '../data/FbHttp'; +import { FbDb } from '../data/FbDb'; +import { FB_ATTACH } from '../data/FbAttachQueue'; +import { FB_KEY_STORE } from '../data/FbKeyStore'; +import { fbSyncState } from '../common/FbSyncStore'; import { common } from '@kit.AbilityKit'; /** @@ -165,10 +169,11 @@ struct Index { const r = await FB_SESSION.check(ctx); if (r.state === SESSION_NEED_LOGIN) { - this.app.lockReason = r.reason; - this.app.loginStartStep = 2; - this.app.needLogin = true; - this.app.offlineLocked = false; + /* 🔴 v58:有令牌却校验失败(被网页端踢掉 / 令牌过期 / 账号停用) + ⇒ 走 `lockOut`,它会**把本地数据一起清掉**。 + ⚠️ 不能只设 needLogin 就完事:那样沙箱里的库和附件还在, + 与用户要的"所有内容都不允许看"差一大截。 */ + this.lockOut(r.reason); this.entered = false; } else if (r.state === SESSION_OFFLINE) { /* 离线超过 24 小时:**锁界面但不删令牌**。 @@ -268,35 +273,76 @@ struct Index { } } /** - * 🔴 v57:令牌失效(任何请求 401)→ 立刻锁定回登录页。 + * 🔴 v57/v58:令牌失效(任何请求 401 / 启动校验发现被踢)→ 锁定回登录页 + * **并把本地数据一起清掉**。 * * 用户原话:「运行期间任何一个请求返回 401,立刻锁定跳登录页 —— * 这是真正的兜底,因为同步/写入本来就在不断发请求。」 + * v58 又加了一句:「退到登录页面之后,这里面所有的资源、所有的内容 + * 都是不允许看的。目的就是为了保证数据的安全性。」 + * ⇒ 只锁界面挡不住"拷走沙箱"。所以四处一起做: + * 令牌 → 数据密钥 → 本地库 → 附件缓存。缺任何一处,那条路都还通。 * - * ⚠️ 由 `FbHttp` 的网络层统一回调(不是每个页面自己判), - * 所以**漏不掉**;登录接口自己传了 `authProbe` 不会触发。 + * ⚠️ 由 `FbHttp` 的网络层统一回调(不是每个页面自己判),所以**漏不掉**; + * 登录接口自己传了 `authProbe` 不会触发。 + * + * @param reason 给用户看的原因('' = 用默认文案) */ - private lockOut(): void { + private lockOut(reason?: string): void { if (this.app.needLogin) { return; // 已经在登录页了,别反复设状态(会让界面闪) } const ctx: common.Context | undefined = this.abilityCtx(); if (ctx !== undefined) { - // ⚠️ 先清本地令牌(主防线:捡到手机的人拿不到它),再通知界面 + // ⚠️ 先清本地令牌(主防线:捡到手机的人拿不到它),再清数据 FB_TOKEN_STORE.clear(ctx).catch((err: BusinessError): void => { hilog.warn(0x0000, 'FamilyBook', '清令牌失败:%{public}s', JSON.stringify(err)); }); FB_SESSION.clear(ctx).catch((err: BusinessError): void => { hilog.warn(0x0000, 'FamilyBook', '清会话记录失败:%{public}s', JSON.stringify(err)); }); + this.wipeLocalData(ctx); } - this.app.lockReason = '这台设备已经被退出登录,请重新登录'; + this.app.lockReason = reason !== undefined && reason !== '' + ? reason : '这台设备已经被退出登录,请重新登录'; this.app.loginStartStep = 2; this.app.needLogin = true; this.app.offlineLocked = false; this.entered = false; } + /** + * 清空本地数据(与设置页的「退出登录」走**同一套**动作)。 + * + * ⚠️ 为什么不在 `FbSession` 里做:会话层只管"能不能用"这个判定, + * 清数据要碰库、碰文件、碰 UI 状态,把它塞进会话层会让那层不再纯粹。 + * 而 `FbSession` 的两个返回值(need_login / offline)最终都汇到 `Index`, + * 在这里统一编排刚好只写一份。 + */ + private wipeLocalData(ctx: common.Context): void { + this.doWipe(ctx).catch((err: BusinessError): void => { + hilog.warn(0x0000, 'FamilyBook', '清本地数据异常:%{public}s', JSON.stringify(err)); + }); + } + + /** 实际动作(拆出来是为了能 await 又能统一兜异常) */ + private async doWipe(ctx: common.Context): Promise { + // ① 附件(内部第一步就是**吊销数据密钥** → 已落盘的密文永久解不开) + try { + await FB_ATTACH.wipe(ctx); + } catch (err) { + hilog.warn(0x0000, 'FamilyBook', '清附件失败:%{public}s', JSON.stringify(err)); + } + // ② 本地库 + try { + await FbDb.inst().wipe(ctx); + } catch (err) { + hilog.warn(0x0000, 'FamilyBook', '清库失败:%{public}s', JSON.stringify(err)); + } + // ③ 同步进度(别把上一账号的状态带到下一个) + fbSyncState().reset(); + } + /** 登录成功:进主界面(会话信息已由登录页写进 `app.session`) */ private onLoggedIn(): void { this.app.session.loggedIn = true; @@ -307,6 +353,52 @@ struct Index { this.app.lockReason = ''; this.app.offlineLocked = false; this.entered = true; + // 🔴 v58:重新登录后**必须把本地库重新打开**。 + // 被踢/退出时 `doWipe()` 把库删了(store=null),如果不在这里重开, + // 整个应用之后都只能"读网络、不写缓存"—— 界面能用,但一断网就全空, + // 而且没有任何报错(`isReady()` 只是返回 false)。这是最难查的那类问题。 + this.reopenLocal(); + } + + /** + * 重新建立本地库 + 附件索引(登录成功后调)。 + * + * ⚠️ 不 await:开库是异步的,挡住"进入首页"会让登录后多转圈半秒; + * 各页面在库没好时会自动退化成"只走网络",不会出错。 + * ⚠️ 顺序:先开库,再让附件队列重建索引(它要读 `attach_cache`)。 + */ + private reopenLocal(): void { + const ctx: common.Context | undefined = this.abilityCtx(); + if (ctx === undefined) { + return; + } + this.doReopen(ctx).catch((err: BusinessError): void => { + hilog.warn(0x0000, 'FamilyBook', '重建本地环境异常:%{public}s', JSON.stringify(err)); + }); + } + + /** + * 三段:① 数据密钥(wipe 时被吊销了)② 本地库(wipe 时被删了)③ 附件索引(要读库)。 + * + * ⚠️ 每段**各自兜异常**:一个环节失败不该让后面全都不做 + * (比如 HUKS 不可用时,库和附件索引仍然要能建起来)。 + */ + private async doReopen(ctx: common.Context): Promise { + try { + await FB_KEY_STORE.init(ctx); + } catch (err) { + hilog.warn(0x0000, 'FamilyBook', '重建数据密钥失败:%{public}s', JSON.stringify(err)); + } + try { + await FbDb.inst().open(ctx); + } catch (err) { + hilog.warn(0x0000, 'FamilyBook', '重开本地库失败:%{public}s', JSON.stringify(err)); + } + try { + await FB_ATTACH.init(ctx); + } catch (err) { + hilog.warn(0x0000, 'FamilyBook', '重建附件索引失败:%{public}s', JSON.stringify(err)); + } } /** diff --git a/entry/src/main/ets/pages/movies/FbMoviesPage.ets b/entry/src/main/ets/pages/movies/FbMoviesPage.ets index adaa267..db3cbae 100644 --- a/entry/src/main/ets/pages/movies/FbMoviesPage.ets +++ b/entry/src/main/ets/pages/movies/FbMoviesPage.ets @@ -34,11 +34,11 @@ import { FbCinemaSheet } from '../../components/FbCinemaSheet'; import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet'; import { FB_SET_MOVIES } from '../../model/FbSettingsMenus'; import { FB_TOKEN_STORE } from '../../data/FbTokenStore'; -import { fbImageUrl } from '../../data/FbHttp'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { common } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; +import { fbImageSrc } from '../../data/FbAttachQueue'; const DOMAIN: number = 0x0000; const TAG: string = 'FbMoviesPage'; @@ -554,7 +554,7 @@ export struct FbMoviesPage { if (mv.posterKey !== '' && !this.posterFailed(mv.id)) { // 🔴 真海报:走服务端 `media.php` 代理,令牌用 URL query 带过去 // (`Image()` 无法加自定义请求头,见 fbImageUrl 的说明)。 - Image(fbImageUrl(mv.posterKey, FB_TOKEN_STORE.get())) + Image(fbImageSrc(mv.posterKey, FB_TOKEN_STORE.get())) .width('100%') .height('100%') .objectFit(ImageFit.Cover) diff --git a/entry/src/main/ets/pages/people/FbPeoplePage.ets b/entry/src/main/ets/pages/people/FbPeoplePage.ets index c9cbc8f..b32b0f3 100644 --- a/entry/src/main/ets/pages/people/FbPeoplePage.ets +++ b/entry/src/main/ets/pages/people/FbPeoplePage.ets @@ -22,7 +22,6 @@ import { FbPersonSheet } from '../../components/FbPersonSheet'; import { FbPersonEditSheet } from '../../components/FbPersonEditSheet'; import { FbKinGraphView } from '../../components/FbKinGraph'; import { FbTagChip } from '../../components/FbTagChip'; -import { fbImageUrl } from '../../data/FbHttp'; import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { FbDb } from '../../data/FbDb'; import { FbPersonRepo, FbRosterPerson, FbRosterSyncResult, FbTagCount } from '../../data/FbPersonRepo'; @@ -32,6 +31,7 @@ import { call } from '@kit.TelephonyKit'; import { common } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll'; +import { fbImageSrc } from '../../data/FbAttachQueue'; const DOMAIN: number = 0x0000; const TAG: string = 'FbPeoplePage'; @@ -838,7 +838,7 @@ export struct FbPeoplePage { color: p.avatarColor, dia: 40, fontSizeVp: 16, - imageUrl: fbImageUrl(p.avatar, FB_TOKEN_STORE.get()) + imageUrl: fbImageSrc(p.avatar, FB_TOKEN_STORE.get()) }) Column({ space: 3 }) { diff --git a/entry/src/main/ets/pages/settings/FbSettingsPage.ets b/entry/src/main/ets/pages/settings/FbSettingsPage.ets index 81cb26e..0537a94 100644 --- a/entry/src/main/ets/pages/settings/FbSettingsPage.ets +++ b/entry/src/main/ets/pages/settings/FbSettingsPage.ets @@ -17,7 +17,11 @@ import { FbAvatar, FbDot, FbPrimaryButton, FbTag } from '../../components/Widget import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { FB_TOKEN_STORE } from '../../data/FbTokenStore'; import { FB_SERVER_STORE } from '../../data/FbServerStore'; -import { FB_SYNC_ALL_PROGRESS, FbSyncAllResult, syncAll } from '../../data/FbSyncAll'; +import { FbSyncAllResult, syncAll } from '../../data/FbSyncAll'; +import { FbSyncState, fbSyncState } from '../../common/FbSyncStore'; +import { FbDb } from '../../data/FbDb'; +import { FB_ATTACH } from '../../data/FbAttachQueue'; +import { FB_KEY_STORE } from '../../data/FbKeyStore'; import { common } from '@kit.AbilityKit'; import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; import { hilog } from '@kit.PerformanceAnalysisKit'; @@ -104,8 +108,13 @@ export struct FbSettingsPage { this.tokenNote = '正在同步…'; // 实时进度(定时器把当前步骤刷到界面上)。 // ⚠️ 本机 hilog 读不出内容 ⇒ 不给实时进度,用户会以为卡死。 + // ⚠️ v59 起进度在 `fbSyncState()` 里(顶栏图标也订阅同一份), + // 不再读 `FbSyncAll` 的模块级字符串 —— 那种 `export let` 的"实时值" + // 在打包器内联之后就停住不动了(详见 FbSyncStore 的说明)。 const timer: number = setInterval((): void => { - this.tokenNote = '同步中…(' + FB_SYNC_ALL_PROGRESS + ')'; + const s: FbSyncState = fbSyncState(); + this.tokenNote = '同步中…(' + s.current + ' ' + + s.done.toString() + '/' + s.total.toString() + ')'; }, 700); const res: FbSyncAllResult = await syncAll(); clearInterval(timer); @@ -113,11 +122,20 @@ export struct FbSettingsPage { } /** - * 退出登录:清令牌 + 回登录页(**地址保留**)。 + * 退出登录:清令牌 + 清本地数据 + 回登录页(**地址保留**)。 * - * 🔴 为什么不顺手清本地数据:本地优先架构下"没登录"不等于"没数据"。 - * 用户可能只是在别人手机上登出、或暂时断网 —— 把已同步的数据一起删掉, - * 等于让他下次进来面对一片空白(还得重新全量同步)。 + * 🔴 v58 改了这里的行为(用户 2026-10-17): + * 「退到登录页面之后,这里面所有的资源、所有的内容都是不允许看的。 + * 目的就是为了保证数据的安全性。」 + * ⇒ 退出登录 = **主动清空**本地库 + 附件 + 数据密钥。 + * 之前那句"不顺手清本地数据、否则下次面对一片空白"的顾虑, + * 在**重新登录会自动全量同步**之后已经不成立 —— 用户下次进来 + * 看到的是"正在同步",而不是空白。 + * + * ⚠️ 顺序:**先清令牌**(主防线,捡到手机的人拿不到),再清数据,最后才尽力通知服务端。 + * 通知失败不报错:它只是让这台设备从列表里消失得更快,删不掉就等 24h 自然过期。 + * ⚠️ 附件清理里已经包含了"吊销数据密钥"(`FB_ATTACH.wipe` 的第一步), + * 所以这里不必再单独调一次 `FB_KEY_STORE.revoke`。 */ private async logout(): Promise { const ctx: common.Context | undefined = this.getUIContext().getHostContext(); @@ -125,15 +143,13 @@ export struct FbSettingsPage { this.tokenNote = '拿不到应用上下文,无法退出'; return; } - /* 🔴 v57(用户 2026-10-17):「先删本地令牌(这是主防线,捡到手机的人拿不到) - → 再尽力通知服务端」。 - ⚠️ 顺序不能反:离线时通知发不出去,如果等它成功再清本地, - 用户会看到"点了退出却没退"。本地那一刀是唯一可靠的。 - ⚠️ 服务端那一条**失败也不报错**:它只是让"这台设备"从设备列表里消失得 - 更及时,删不掉就等它 24h 后自然过期,或用户在网页端手动踢。 */ const token: string = FB_TOKEN_STORE.get(); + // ① 令牌(主防线) await FB_TOKEN_STORE.clear(ctx); await FB_SESSION.clear(ctx); + // ② 本地数据:库 + 附件(含吊销密钥)+ 同步进度 + await this.wipeLocalData(ctx); + // ③ 尽力通知服务端 if (token !== '') { fbApi('/api/v1/logout', { method: 'POST', token: token, authProbe: true }) .catch((err: BusinessError): void => { @@ -148,6 +164,30 @@ export struct FbSettingsPage { this.app.needLogin = true; } + /** + * 🔴 清空本地数据(退出登录 / 被远程序号移除**共用**这一份)。 + * + * 三件事一起做才算"读不出": + * ① 吊销数据密钥(`FB_ATTACH.wipe` 的第一步)→ 已落盘的附件密文永久解不开; + * ② 删库(RDB 整库加密,删掉即失去密钥意义); + * ③ 复位同步进度(别把上一账号的"上次同步时间"带给下一个账号)。 + * + * ⚠️ 失败**不抛**:无论如何都要回登录页 —— 那才是用户的主诉求。 + */ + private async wipeLocalData(ctx: common.Context): Promise { + try { + await FB_ATTACH.wipe(ctx); + } catch (err) { + hilog.warn(DOMAIN, TAG, '清附件失败:%{public}s', JSON.stringify(err)); + } + try { + await FbDb.inst().wipe(ctx); + } catch (err) { + hilog.warn(DOMAIN, TAG, '清库失败:%{public}s', JSON.stringify(err)); + } + fbSyncState().reset(); + } + /** * 更换服务器:回登录页第一步重填地址。 * @@ -439,6 +479,19 @@ export struct FbSettingsPage { 用户 2026-10-17:「只有 uuid 用户无法确定是哪台设备。」 ⇒ 网页端「设备管理」里显示的就是这个名字,用户要能对上号。 */ this.plainRow('这台设备', FB_DEVICE_STORE.getDeviceName()) + /* 🔴 v58:本机数据到底加没加密 —— 必须**如实显示**。 + 用户 2026-10-17 定的 L3 是"数据在但读不出", + 而代码里有一条"HUKS 不可用时降级明文"的兜底(见 FbKeyStore)。 + 那条兜底如果不显示出来,用户会一直以为自己的数据是加密的 —— + 这比不加密更糟(虚假的安全感)。 + ⚠️ 这两个值在打开设置页时才读一次(不是 @Trace), + 所以放在 build 里直接算:库/密钥状态在进入设置页后基本不会变。 */ + this.plainRow('本机数据', FbDb.inst().isReady() + ? (FbDb.inst().isEncrypted() ? '已加密' : '未加密(本机不支持加密)') + : '准备中') + this.plainRow('附件密钥', FB_KEY_STORE.isWeak() + ? '已降级(明文保存)' + : (FB_KEY_STORE.dek() !== null ? 'HUKS 保护中' : '未建立')) } .padding(FbMetric.cardPad) .backgroundColor(FbColors.surface)