v0.8.0 本地加密 / 被踢清数据 / 增量同步 / 附件队列 / 顶栏同步进度

用户 2026-10-18 两条:
「直接做 L3 锁界面 + 本地加密(HUKS,踢设备即吊销密钥):数据在但读不出,
  重登后恢复密钥即可用。」
「右上角头像和提醒前面加同步进度…首次全量、之后增量;进来先展示本地数据,
  一边同步一边更新;附件后台队列同步(名册头像 / 电影海报 / 观影照片 / 资源附件)。」

① 整库加密(FbSchema v4→v5,新增 module_sync / attach_cache 两张表)
   RDB encrypt=true。加密属性一经创建不可修改 ⇒ openWithEncryption 三段式:
   直接开 → 失败删库重建 → 再失败降级明文(degraded 字段,设置页如实显示,
   不静默降级)。

② 两层密钥(FbKeyStore)
   HUKS 里 KEK(fb_kek_v1,不可导出)包裹 32 字节 DEK;revoke() 删 KEK
   ⇒ 已落盘附件密文在数学上解不开,这就是"踢设备即吊销密钥"。
   重新登录重新生成 DEK 并重下附件(不做服务端托管,为了省一次重下
   把能解你本地数据的密钥放到服务端不值)。
   会话属性里 PURPOSE 只出现一次:原来把含 ENCRYPT|DECRYPT 的生成属性直接
   拿去开会话再 push 一个 PURPOSE,同一 tag 出现两次在 HUKS 里行为未定义
   ⇒ 拆成 kekBaseProps / kekGenProps / kekSessionProps。

③ AES-256-GCM(FbCrypto)
   落盘 iv(12)|ct|tag(16);变换串必须带 padding 段(AES256|GCM|PKCS7,
   缺了抛 801);解密从尾部切 tag。

④ 附件后台队列(FbAttachQueue)
   attach_cache 台账(pending/done/fail,MAX_TRIES=3);密文放 filesDir、
   明文临时放 cacheDir;扩展名白名单筛 key。
   图片地址收成统一入口 fbImageSrc(key,token):本地有就用 file://,
   没有才回网络 —— 收成一个函数才漏不掉(5 个页面全部改过来)。

⑤ 被踢 / 退出清数据
   lockOut → 清令牌 → FB_ATTACH.wipe(第一步就是吊销 KEK)→ FbDb.wipe
   (先关句柄再 deleteRdbStore,并发去重,open 会等它)→ 同步状态复位。
   重新登录后重建密钥 + 库 + 附件索引,否则之后只读网络不写缓存且不报错。

⑥ 增量同步(FbSyncAll 重写)
   先问 /api/v1/changes;指纹相同就跳过。本地没水位必须拉(否则老用户升级后
   一个模块都不同步);24h 陈旧上限兜"改无 updated_at 表的已有行";
   /changes 拿不到时判 ping status>0 —— 断网直接收工,不退回全量
   (否则 12 个模块各等 15 秒)。
   家谱也进了预同步列表(原来只在打开时拉,离线是一片空白);
   总步数由 CACHE_MODULES.length 算出,不写死。

⑦ 删除传播
   服务端是物理删除、没有墓碑 ⇒ 观影同步改成"先清后插"
   (persons 跨模块共用,不清)。

⑧ 顶栏同步进度
   头像与铃铛之间加三态按钮(转圈 / 红感叹号 / 灰对勾)+ bindSheet 面板:
   进度条、「N 个模块没有变化」、附件行、失败清单、立即同步(防连点)。
   状态中心 FbSyncState(@ObservedV2 + @Trace,AppStorageV2 单例);
   界面直接读 @Trace 字段,不提供派生 getter。

验证:hvigor BUILD SUCCESSFUL;_fb_arkts_guard.js 290/0(本批新增 59 条);
_fb_v58_falsify.py 31 项注入 38/0;b57 反向验证 12/0(锚点跟上本批 logout 改动);
本地 e2e 1519/0;全量流水线 PUSH_RC=0。
This commit is contained in:
2026-10-18 16:01:41 +08:00
parent 776c95ba0b
commit 2132ee42d2
19 changed files with 2202 additions and 118 deletions
+2 -2
View File
@@ -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"
+181
View File
@@ -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())!;
}
@@ -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)
@@ -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 {
@@ -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) */
+245
View File
@@ -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 : '家书';
}
/**
* 手动同步(面板里的按钮)。
*
* ⚠️ 已经在跑就直接返回:`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';
}
+530
View File
@@ -0,0 +1,530 @@
/**
* 家书 FamilyBook · 附件后台同步队列(鸿蒙端,v59)
*
* 🔴 用户 2026-10-17 的原话:
* 「使用同步的功能,把所有的数据全部同步到我们的这个应用下面,包括一些附件,
* 比方说名册里面的头像啊,电影观影模块里面的这种海报啊,观影照片啊,
* 还有资源里面的一些附件啊等等,所有的这些全部同步下来。」
* 「这个时间可能会比较久,所以应该在那个右上角的……加一个同步的进度的这样一个按钮」
*
* 三件事:
* ① **找**:从已经落地的数据里把附件相对键扫出来(见 `collectFromJson`);
* ② **下**:逐个下载,AES-GCM 加密后存沙箱(`filesDir/attachments`);
* ③ **给界面**:解密一份到 `cacheDir/fb_img`,`localPath(key)` **同步**返回
* `file://` 路径,界面直接 `Image(...)` 用,不额外转圈。
*
* 🔴 为什么加密:
* 附件(头像、观影照片)是用户点名要"同步下来"的**私人**内容。
* 库已经整库加密(RDB `encrypt`),附件如果明文躺在沙箱里,
* "被踢之后什么都读不出"就有个洞 —— 拷走 attachments 目录就全拿到了。
* ⇒ 密文存在 `filesDir`,明文只出现在 `cacheDir`(系统可随时清理,被踢时主动清)。
*
* 🔴 为什么**顺序**下载、不做并发:
* 线上全量附件目前是 157 个文件 / 13MB 量级,顺序 2~3 分钟;
* 而顺序实现让"断点续传/失败退避/进度百分比"都变成一件简单的事。
* 并发只会让这三件事各复杂一倍,收益却几乎看不到。
* ⚠️ 将来附件量真上去了,只需把 `run()` 里那个 for 换成 2~3 路 worker。
*
* ⚠️ 网络地址仍走 `media.php?k=<key>&token=<token>`(见 `fbImageUrl`):
* 这是服务端唯一的附件出口,`Image()` 加不了请求头,令牌只能放 query。
*/
import { http } from '@kit.NetworkKit';
import { fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { cryptoFramework } from '@kit.CryptoArchitectureKit';
import { util } from '@kit.ArkTS';
import { FbDb, FbRow } from './FbDb';
import { FB_KEY_STORE } from './FbKeyStore';
import { fbAesDecrypt, fbAesEncrypt } from './FbCrypto';
import { fbImageUrl } from './FbHttp';
import { FB_TOKEN_STORE } from './FbTokenStore';
import { fbSyncState } from '../common/FbSyncStore';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbAttach';
/** 密文目录(`filesDir` 下,不会被系统清理) */
const DIR_ENC: string = 'attachments';
/** 明文临时目录(`cacheDir` 下,系统可清、被踢时主动清) */
const DIR_TMP: string = 'fb_img';
/** 同一张图最多重试几次(超过就标 fail,不再自动撞) */
const MAX_TRIES: number = 3;
/** 只有这些扩展名才当成附件 —— 防止把 `source_ref` 之类误当文件下载 */
const EXT_WHITELIST: string[] = [
'jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'heic',
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
'zip', 'rar', '7z', 'mp4', 'mov', 'mp3', 'wav', 'txt', 'md'
];
/** 队列里的一行 */
interface FbAttachItem {
key: string;
tries: number;
}
class FbAttachQueueImpl {
private dirEnc: string = '';
private dirTmp: string = '';
/** key → 可直接给 `Image` 的本地明文路径(`file://...`) */
private localMap: Map<string, string> = new Map<string, string>();
private dirsReady: boolean = false;
private running: boolean = false;
/**
* 建目录 + 恢复内存索引(启动时调一次)。
*
* 🔴 为什么要"恢复索引"而不是每次显示时查表:
* `Image()` 在 build 里要**同步**拿路径。查库是异步的,来不及。
* ⇒ 内存里存一份 Map,只在 `init` 和下载完成时更新。
*/
async init(ctx: common.Context): Promise<void> {
if (this.dirsReady) {
return;
}
try {
this.dirEnc = ctx.filesDir + '/' + DIR_ENC;
this.dirTmp = ctx.cacheDir + '/' + DIR_TMP;
if (!fileIo.accessSync(this.dirEnc)) {
fileIo.mkdirSync(this.dirEnc, true);
}
if (!fileIo.accessSync(this.dirTmp)) {
fileIo.mkdirSync(this.dirTmp, true);
}
this.dirsReady = true;
} catch (err) {
hilog.error(DOMAIN, TAG, '建附件目录失败:%{public}s', JSON.stringify(err));
return;
}
await this.rebuildIndex();
}
/** 从 `attach_cache` 把"下好了的"回填进内存 Map(并补解密缺失的明文临时文件) */
private async rebuildIndex(): Promise<void> {
this.localMap.clear();
const db: FbDb = FbDb.inst();
if (!db.isReady()) {
return;
}
try {
const rows: FbRow[] = await db.queryRows(
"SELECT key, path FROM attach_cache WHERE state = 'done'");
const key: Uint8Array | null = FB_KEY_STORE.dek();
for (const r of rows) {
const k: string = r.strOr('key', '');
const p: string = r.strOr('path', '');
if (k === '' || p === '') {
continue;
}
const tmpPath: string = this.tmpPath(k);
// 明文临时文件可能被系统清了 —— 有密钥就补解一次
if (!fileIo.accessSync(tmpPath) && key !== null) {
await this.decryptToTmp(k, key);
}
if (fileIo.accessSync(tmpPath)) {
this.localMap.set(k, 'file://' + tmpPath);
}
}
hilog.info(DOMAIN, TAG, '附件索引恢复 %{public}d 条', this.localMap.size);
} catch (err) {
hilog.warn(DOMAIN, TAG, '恢复附件索引失败:%{public}s', JSON.stringify(err));
}
}
/** 同步查:这张图本地有吗('' = 没有,调用方回退用网络地址) */
localPath(key: string): string {
if (key === '') {
return '';
}
const p: string | undefined = this.localMap.get(key);
return p !== undefined ? p : '';
}
/** 已缓存条数(面板显示) */
cachedCount(): number {
return this.localMap.size;
}
/**
* 把一批附件键登记进待下载队列(已有的跳过、失败过的按次数决定要不要再试)。
*
* @returns 登记后"还需要下载"的条数
*/
async collectKeys(keys: string[]): Promise<number> {
const db: FbDb = FbDb.inst();
if (!db.isReady() || keys.length === 0) {
return 0;
}
let added: number = 0;
try {
for (const k of keys) {
if (k === '' || this.localMap.has(k)) {
continue;
}
// `INSERT OR IGNORE`:已有台账的行不动(保住 tries 计数,否则会被重置成永远重试)
await db.exec(
'INSERT OR IGNORE INTO attach_cache (key, path, size, state, tries, updated_at) '
+ "VALUES (?, '', 0, 'pending', 0, ?)", [k, Date.now()]);
added += 1;
}
} catch (err) {
hilog.warn(DOMAIN, TAG, '登记附件失败:%{public}s', JSON.stringify(err));
}
return added;
}
/**
* 跑队列(幂等:重复调用时后一个直接返回)。
*
* 进度写进 `FbSyncState`(顶栏图标与面板订阅它)。
*/
async run(): Promise<void> {
if (this.running) {
return;
}
this.running = true;
const st = fbSyncState();
const db: FbDb = FbDb.inst();
try {
if (!db.isReady()) {
return;
}
const rows: FbRow[] = await db.queryRows(
"SELECT key, tries FROM attach_cache WHERE state = 'pending' ORDER BY updated_at ASC");
const total: number = rows.length;
if (total === 0) {
st.attachFinish();
return;
}
// 已缓存字节数(面板上"已缓存 12.3 MB")
const bytes0: number = await db.scalar(
"SELECT COALESCE(SUM(size), 0) FROM attach_cache WHERE state = 'done'");
st.attachBegin(total, bytes0);
const token: string = FB_TOKEN_STORE.get();
const key: Uint8Array | null = FB_KEY_STORE.dek();
if (key === null) {
// 没有数据密钥就下不了(下了也加密不了)—— 这不是错误,等下次 init 后再说
hilog.warn(DOMAIN, TAG, '没有可用的数据密钥,跳过附件下载');
st.attachFinish();
return;
}
let done: number = 0;
let bytes: number = bytes0;
for (const r of rows) {
const k: string = r.strOr('key', '');
const tries: number = r.num('tries', 0);
const got: number = await this.fetchOne(k, token, key);
if (got > 0) {
bytes += got;
} else {
const nextTries: number = tries + 1;
await db.exec(
'UPDATE attach_cache SET tries = ?, state = ?, updated_at = ? WHERE key = ?',
[nextTries, nextTries >= MAX_TRIES ? 'fail' : 'pending', Date.now(), k]);
}
done += 1;
st.attachStep(done, bytes);
}
hilog.info(DOMAIN, TAG, '附件队列跑完:%{public}d 条', done);
} catch (err) {
hilog.warn(DOMAIN, TAG, '附件队列异常:%{public}s', JSON.stringify(err));
} finally {
this.running = false;
st.attachFinish();
}
}
/**
* 下载一个附件并落盘(加密 + 解密一份给界面)。
*
* @returns 写入的字节数(0 = 失败)
*/
private async fetchOne(key: string, token: string, dek: Uint8Array): Promise<number> {
const url: string = fbImageUrl(key, token);
if (url === '') {
return 0;
}
const req: http.HttpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await req.request(url, {
method: http.RequestMethod.GET,
connectTimeout: 15000,
readTimeout: 30000,
expectDataType: http.HttpDataType.ARRAY_BUFFER
});
if (resp.responseCode !== 200) {
hilog.warn(DOMAIN, TAG, '附件 %{public}s 下载失败:HTTP %{public}d', key, resp.responseCode);
return 0;
}
const raw: ArrayBuffer | string = resp.result as ArrayBuffer | string;
if (typeof raw === 'string') {
return 0; // 期望的是二进制;回了字符串说明是错误页
}
const plain: Uint8Array = new Uint8Array(raw);
if (plain.length === 0) {
return 0;
}
const enc: Uint8Array | null = await fbAesEncrypt(dek, plain);
if (enc === null) {
return 0;
}
const name: string = this.fileName(key);
const encPath: string = this.dirEnc + '/' + name + '.enc';
this.writeAll(encPath, enc.buffer.slice(enc.byteOffset, enc.byteOffset + enc.byteLength));
// 给界面的明文临时文件(同一份数据,不重复解密)
const tmpPath: string = this.dirTmp + '/' + name + this.extOf(key);
this.writeAll(tmpPath, raw);
this.localMap.set(key, 'file://' + tmpPath);
await FbDb.inst().exec(
"UPDATE attach_cache SET path = ?, size = ?, state = 'done', tries = 0, updated_at = ? "
+ 'WHERE key = ?', [name, plain.length, Date.now(), key]);
return plain.length;
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '附件 %{public}s 下载异常:%{public}s', key, JSON.stringify(e));
return 0;
} finally {
req.destroy();
}
}
/** 用密文重新解出一份明文临时文件 */
private async decryptToTmp(key: string, dek: Uint8Array): Promise<boolean> {
try {
const name: string = this.fileName(key);
const encPath: string = this.dirEnc + '/' + name + '.enc';
if (!fileIo.accessSync(encPath)) {
return false;
}
const blob: Uint8Array | null = this.readAll(encPath);
if (blob === null) {
return false;
}
const plain: Uint8Array | null = await fbAesDecrypt(dek, blob);
if (plain === null) {
return false;
}
const tmpPath: string = this.dirTmp + '/' + name + this.extOf(key);
this.writeAll(tmpPath, plain.buffer.slice(plain.byteOffset, plain.byteOffset + plain.byteLength));
this.localMap.set(key, 'file://' + tmpPath);
return true;
} catch (err) {
return false;
}
}
/**
* 🔴 **清空全部附件**(被踢 / 退出登录)。
*
* 顺序:先吊销密钥(这样即使目录没删掉,密文也解不开)→ 再删目录 → 再清表。
* ⚠️ 删目录失败不抛:密钥已经吊销,剩下的只是无意义的字节。
*/
async wipe(ctx: common.Context): Promise<void> {
await FB_KEY_STORE.revoke(ctx);
this.localMap.clear();
try {
if (this.dirEnc !== '' && fileIo.accessSync(this.dirEnc)) {
this.removeDir(this.dirEnc);
}
if (this.dirTmp !== '' && fileIo.accessSync(this.dirTmp)) {
this.removeDir(this.dirTmp);
}
} catch (err) {
hilog.warn(DOMAIN, TAG, '清附件目录失败:%{public}s', JSON.stringify(err));
}
try {
if (FbDb.inst().isReady()) {
await FbDb.inst().exec('DELETE FROM attach_cache');
}
} catch (err) {
// 库可能已经被删了(wipe 的调用方通常先清库)—— 不算问题
hilog.info(DOMAIN, TAG, '清附件台账时库不可用(多半已删库):忽略');
}
this.dirsReady = false;
}
/* ---- 从已经落地的数据里扫附件键 ----
🔴 为什么用"递归 + 扩展名白名单"而不是硬编码字段名:
附件字段在各模块里叫法不一(poster_key / avatar / photos[] / cover_key …),
将来服务端加一个字段就要改一次端上。递归扫 + 白名单是"宽进严出":
结构随便变,只要值像文件路径就收 —— 而误收的代价只是多下一个小文件。
⚠️ 前提是服务端附件值**确实是相对键**(`posters/xxx.jpg`),不是完整 URL。
这条由服务端的 `media.php` 约定保证(见 FbHttp 的说明)。 */
collectFromJson(json: string, out: string[]): void {
if (json === '') {
return;
}
try {
const root: Object = JSON.parse(json) as Object;
this.walk(root, out);
} catch (err) {
// 非 JSON(理论上不会)—— 忽略
}
}
private walk(node: Object, out: string[]): void {
if (node === null || node === undefined) {
return;
}
if (typeof node === 'string') {
if (this.looksLikeAttach(node)) {
out.push(node);
}
return;
}
if (node instanceof Array) {
const arr: Object[] = node as Object[];
for (const it of arr) {
this.walk(it, out);
}
return;
}
if (typeof node === 'object') {
const rec: Record<string, Object> = node as Record<string, Object>;
const ks: string[] = Object.keys(rec);
for (const k of ks) {
const v: Object = rec[k];
// 字段名本身也提示一下(`poster_key` 的 value 万一是空串就跳过)
if (typeof v === 'string' && v !== '' && this.looksLikeAttach(v)) {
out.push(v);
} else if (typeof v === 'object') {
this.walk(v, out);
}
}
}
}
/** 值像不像一个附件相对键:`xxx/yyy.ext` 且扩展名在白名单里 */
private looksLikeAttach(s: string): boolean {
if (s.length < 5 || s.length > 400) {
return false;
}
if (s.indexOf('/') < 0 || s.startsWith('http') || s.indexOf('://') >= 0) {
return false;
}
const ext: string = this.extOf(s).replace('.', '').toLowerCase();
return EXT_WHITELIST.indexOf(ext) >= 0;
}
/* ---- 文件小工具 ---- */
private extOf(p: string): string {
const dot: number = p.lastIndexOf('.');
const slash: number = p.lastIndexOf('/');
if (dot <= slash) {
return '';
}
const e: string = p.substring(dot);
return e.length > 8 ? '' : e;
}
/** key → 稳定文件名(sha256 十六进制),避免路径穿越与特殊字符 */
private fileName(key: string): string {
try {
const md: cryptoFramework.Md = cryptoFramework.createMd('SHA256');
const enc: util.TextEncoder = new util.TextEncoder();
// ⚠️ update 是异步的,但这里只需要一次,用同步的 digestSync 更省事
md.updateSync({ data: enc.encodeInto(key) });
const d: cryptoFramework.DataBlob = md.digestSync();
let hex: string = '';
for (let i = 0; i < d.data.length; i++) {
hex += ('0' + d.data[i].toString(16)).slice(-2);
}
return hex;
} catch (err) {
// 兜底:把不安全字符换掉(不会路径穿越,因为连 `/` 也换掉了)
return key.replace(/[^a-zA-Z0-9._-]/g, '_').substring(0, 80);
}
}
private tmpPath(key: string): string {
return this.dirTmp + '/' + this.fileName(key) + this.extOf(key);
}
private writeAll(path: string, buf: ArrayBuffer): boolean {
try {
const f: fileIo.File = fileIo.openSync(path,
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
try {
fileIo.writeSync(f.fd, buf);
} finally {
fileIo.closeSync(f);
}
return true;
} catch (err) {
hilog.warn(DOMAIN, TAG, '写文件失败 %{public}s:%{public}s', path, JSON.stringify(err));
return false;
}
}
private readAll(path: string): Uint8Array | null {
try {
const f: fileIo.File = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
try {
const size: number = fileIo.statSync(f.fd).size;
if (size <= 0) {
return null;
}
const buf: ArrayBuffer = new ArrayBuffer(size);
const n: number = fileIo.readSync(f.fd, buf);
return new Uint8Array(buf.slice(0, n));
} finally {
fileIo.closeSync(f);
}
} catch (err) {
return null;
}
}
/** 删目录(没有递归删除的系统调用,自己走一层) */
private removeDir(dir: string): void {
try {
const names: string[] = fileIo.listFileSync(dir);
for (const n of names) {
try {
fileIo.unlinkSync(dir + '/' + n);
} catch (err) {
// 单个文件删不掉就跳过
}
}
fileIo.rmdirSync(dir);
} catch (err) {
hilog.warn(DOMAIN, TAG, '删目录失败 %{public}s:%{public}s', dir, JSON.stringify(err));
}
}
}
/** 全局单例 */
export const FB_ATTACH: FbAttachQueueImpl = new FbAttachQueueImpl();
/**
* 🔴 v59:**图片显示的统一入口** —— 本地有就用本地文件,没有才走网络。
*
* 为什么要包这么一层(而不是让每个页面自己判断):
* `Image()` 在 build 里拿地址,一旦"本地/网络"的判断分散到 6 个调用点,
* 只要有一处漏了,那张图就永远走网络 —— 而附件同步的全部价值就在"离线能看"。
* 收成一个函数,漏不掉。
*
* ⚠️ 为什么放在这个文件而不是 `FbHttp`:
* `FbAttachQueue` 已经 import 了 `fbImageUrl`;把 `fbImageSrc` 放回 `FbHttp`
* 就会变成两个文件互相 import(循环依赖,ArkTS 下表现为运行期拿到 undefined)。
*
* @param key 服务端附件相对键
* @param token API 令牌(只有走网络时才用得到)
*/
export function fbImageSrc(key: string, token: string): string {
const local: string = FB_ATTACH.localPath(key);
if (local !== '') {
return local;
}
return fbImageUrl(key, token);
}
+111
View File
@@ -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<Uint8Array | null> {
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<Uint8Array | null> {
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<cryptoFramework.SymKey> {
const gen: cryptoFramework.SymKeyGenerator = cryptoFramework.createSymKeyGenerator('AES256');
return await gen.convertKey({ data: key });
}
+165 -3
View File
@@ -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<relationalStore.RdbStore> | null = null;
/** 正在清库的 Promise(`open` 要等它,见 open 里的说明) */
private wipePromise: Promise<boolean> | null = null;
/**
* 打开(必要时创建)本地库并保证结构就绪。
@@ -52,6 +64,11 @@ export class FbDb {
* ⚠️ 不能传 `undefined`——RDB 要靠它定位沙箱目录)
*/
open(ctx: common.Context): Promise<relationalStore.RdbStore> {
// 🔴 v58:正在清库时**不能**打开(会打开一个正在被删除的库,表现为各种诡异错误)。
// 等它清完再走一遍 open —— `wipePromise` 在 finally 里清空,所以只会递归一次。
if (this.wipePromise !== null) {
return this.wipePromise.then((): Promise<relationalStore.RdbStore> => 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<relationalStore.RdbStore> {
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<boolean> {
// 并发去重:两处同时触发(比如"被踢"和"用户自己点了退出")只清一次
if (this.wipePromise !== null) {
return this.wipePromise;
}
const p: Promise<boolean> = this.doWipe(ctx);
this.wipePromise = p;
try {
return await p;
} finally {
this.wipePromise = null;
}
}
/** `wipe` 的实际动作(拆出来只为了包并发去重,逻辑见上) */
private async doWipe(ctx: common.Context): Promise<boolean> {
// ① 先关句柄(必须)
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;
+1 -1
View File
@@ -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 {
+314
View File
@@ -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<boolean> {
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<void> {
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<string | null> {
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<Uint8Array | null> {
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<void> {
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();
+20
View File
@@ -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);
+63 -5
View File
@@ -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
};
+355 -55
View File
@@ -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<string, string> = {
@@ -63,42 +87,68 @@ const MODULE_CN: Record<string, string> = {
'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<string, string | null>;
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<FbSyncAllResult> {
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<boolean>): Promise<void> => {
FB_SYNC_ALL_PROGRESS = label;
st.step(label, done, total);
if (onStep !== undefined) {
onStep(label, done, total);
}
@@ -108,40 +158,171 @@ export async function syncAll(onStep?: FbSyncStepCb): Promise<FbSyncAllResult> {
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;
}
};
// ---- ① 有实体表的两个模块(先做:它们的数据量大、最该先落地)----
/** 记一步"服务端说没变,跳过" */
const skip = (label: string): void => {
st.step(label + '(没变化)', done, total);
if (onStep !== undefined) {
onStep(label, done, total);
}
st.skipOne();
done += 1;
};
// ---- ① 问一句"哪些模块变了" ----
const marks: Map<string, FbModMark> = 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, '服务端不支持变更指纹,退回全量同步');
}
/** 该查什么:true = 需要拉 */
const plan = (name: string): boolean => {
if (fpResult === null) {
return true; // 拿不到指纹 ⇒ 只能全量
}
const mods: Record<string, string | null> = 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<boolean> => {
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<boolean> => {
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']);
}
// ---- ② 其余模块(走通用缓存)----
const cacheModules: string[] = [
'calendar', 'tasks', 'days', 'trips', 'ledger', 'resources', 'books', 'notes',
'notifications', 'home',
];
// ---- ④ 其余 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<boolean> => {
// 这里只关心"有没有成功写进缓存":`load` 的 sink 回调是同步的,
// 用一个 Promise 把它包起来,等到网络那次回来(fromCache=false)才算成功。
const ok: boolean = await new Promise<boolean>((resolve: (v: boolean) => void): void => {
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<boolean> => {
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: note,
skipped: skipped,
};
return result;
}
/* ═══════════════════════════════════════════════════════════════
内部工具
═══════════════════════════════════════════════════════════════ */
/** 拉一个模块的通用缓存(等网络那次回来才算结果) */
function loadOne(repo: FbModuleRepo, name: string, path: string): Promise<boolean> {
return new Promise<boolean>((resolve: (v: boolean) => void): void => {
let settled: boolean = false;
repo.load(name, path, (r: FbModuleLoad): void => {
if (r.fromCache || settled) {
@@ -158,19 +339,138 @@ export async function syncAll(onStep?: FbSyncStepCb): Promise<FbSyncAllResult> {
}
}, 20000);
});
return ok;
});
}
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('、')),
};
FB_SYNC_ALL_PROGRESS = result.note;
return result;
/** 读本地 `module_sync` 记录 */
async function loadMarks(): Promise<Map<string, FbModMark>> {
const out: Map<string, FbModMark> = new Map<string, FbModMark>();
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<void> {
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<FbChangesData | null> {
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<boolean> {
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<void> {
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<string> = new Set<string>();
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);
}
@@ -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<void> => {
return FB_ATTACH.init(this.context);
}).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, 'FamilyBook', '附件/密钥初始化失败:%{public}s', JSON.stringify(err));
});
}
onDestroy(): void {
+102 -10
View File
@@ -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<void> {
// ① 附件(内部第一步就是**吊销数据密钥** → 已落盘的密文永久解不开)
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<void> {
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));
}
}
/**
@@ -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)
@@ -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 }) {
@@ -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<void> {
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<void> {
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)