Files
FamilyBook_harmonyos/entry/src/main/ets/data/FbDb.ets
T

676 lines
30 KiB
Plaintext
Raw Normal View History

/**
* 家书 FamilyBook · 本地库管理器(鸿蒙端)
*
* 职责:打开/创建本地库、首装建表、按版本迁移、提供便捷查询。
*
* 🔴 为什么用**单例**而不是每次 `getRdbStore`:
* RDB 的 store 是可以复用的句柄,反复打开会重复持有连接;
* 而且首装建表/迁移**必须只跑一次**(并发打开会两边都建表、互相报错)。
* ⚠️ 用 Promise 缓存(`openPromise`)而不是"boolean 标志":
* 标志位挡不住并发——两个调用同时进来时,标志都还是 false,
* 于是两个都去打开。缓存 Promise 才能保证"同一时刻只有一次打开"。
*/
import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { FB_DB_NAME, FB_DB_VERSION, FB_SCHEMA_SQL, FB_STORE_CONFIG } from './FbSchema';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbDb';
/**
* 本地库管理器。
*
* ⚠️ 用 class + 模块级单例,而不是 static 方法堆在一个类上:
* 需要持有 `store` 句柄与"打开中"的 Promise,这些是**实例状态**。
*/
export class FbDb {
private store: relationalStore.RdbStore | null = null;
/**
* 最后一次打开/操作失败的说明('' = 没失败)。
*
* 🔴 为什么必须留这么一个字段(而不是只打 hilog):
* **本机设备 hilog 读不出内容**(已知限制,试过 `hilog -x -T xxx` 全是空)。
* 而"本地库没建起来"这种问题**只能从失败原因定位** ——
* 留在内存里、由设置页显示出来,是这台设备上唯一可靠的诊断手段。
* ⚠️ 它**不是**给用户看的正式文案(可能是英文异常),
* 是给开发者看的诊断信息,界面要标成"诊断"。
*/
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;
/**
* 打开(必要时创建)本地库并保证结构就绪。
*
* @param ctx 应用上下文(`getContext()` 得到的能力上下文,
* ⚠️ 不能传 `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);
}
if (this.openPromise !== null) {
return this.openPromise; // 已有人在开,跟着等同一个
}
this.openPromise = this.doOpen(ctx);
return this.openPromise;
}
/** 真正的打开逻辑 */
private async doOpen(ctx: common.Context): Promise<relationalStore.RdbStore> {
try {
// 🔴 诊断:把每一步单独打点。
// 第一次跑时"界面显示走到了同步、但沙箱里没有 .db 文件"——
// 说明**某一步静默失败了**。分步打点才能定位是哪一步。
this.lastStage = '[1] 准备 getRdbStore';
hilog.info(DOMAIN, TAG, '[1] 准备 getRdbStore,name=%{public}s', FB_STORE_CONFIG.name);
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);
// 首装建表:`IF NOT EXISTS` 本身幂等,但**版本迁移**不能这样粗暴跑
await this.createSchemaIfNeeded(store);
this.lastStage = '[3] 建表完成,version=' + store.version.toString();
hilog.info(DOMAIN, TAG, '[3] 建表完成,version=%{public}d', store.version);
await this.migrate(store);
this.lastStage = '[4] 迁移完成,version=' + store.version.toString();
hilog.info(DOMAIN, TAG, '[4] 迁移完成,version=%{public}d', store.version);
this.store = store;
this.lastStage = '[5] 就绪,version=' + store.version.toString();
// ⚠️ 降级必须**一直看得见**,不能被"这次打开成功"掩盖掉
this.lastError = this.degraded
? ('本地库未能加密(已降级为明文):' + this.degradedReason)
: '';
hilog.info(DOMAIN, TAG, '[5] 本地库就绪,版本=%{public}d', store.version);
return store;
} catch (err) {
// ⚠️ 失败时**必须清掉 openPromise**,否则后续调用永远拿到这个失败的 Promise
// (表现为"重试也没用",很难查)。
this.openPromise = null;
const e: BusinessError = err as BusinessError;
// ⚠️ 必须把 message 完整打出来:BusinessError 的 code 常是 14800xxx 这类
// 通用码,光看码猜不出原因,message 里才有"哪个参数不对"。
this.lastError = 'code=' + (e.code !== undefined ? e.code : -1).toString()
+ ' msg=' + (e.message !== undefined ? e.message : JSON.stringify(err));
hilog.error(DOMAIN, TAG, '打开本地库失败:code=%{public}d message=%{public}s',
e.code !== undefined ? e.code : -1,
e.message !== undefined ? e.message : JSON.stringify(err));
throw err as Error;
}
}
/**
* 🔴 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);
}
}
}
/**
* 首装建表。
*
* ⚠️ 判据是 `store.version === 0`(全新库),**不是**"表数量为 0":
* 后者在有"只建了部分表"的脏状态时会误判。
*/
private async createSchemaIfNeeded(store: relationalStore.RdbStore): Promise<void> {
if (store.version !== 0) {
return; // 老库,交给 migrate()
}
for (const sql of FB_SCHEMA_SQL) {
await store.executeSql(sql);
}
store.version = FB_DB_VERSION;
hilog.info(DOMAIN, TAG, '首装建表完成,%{public}d 条语句', FB_SCHEMA_SQL.length);
}
/**
* 按版本迁移。
*
* 🔴 本地库迁移与服务端**完全不同**:
* 服务端可以"每次请求都全量 ALTER 一遍"(它自己就是服务器、失败可重试);
* 用户的手机不能这么干 —— 逐版本、可预期、失败能停在已知状态。
*
* ⚠️ 目前只有 v1(首版),迁移体是空的占位。
* 将来加表/加列时在这里按 `from` 版本逐级 ALTER,**不要**直接改 FB_SCHEMA_SQL ——
* 那只会影响新装用户,老用户升不上来(这是最常见的迁移翻车方式)。
*/
private async migrate(store: relationalStore.RdbStore): Promise<void> {
const from: number = store.version;
if (from === 0 || from >= FB_DB_VERSION) {
return;
}
hilog.info(DOMAIN, TAG, '本地库迁移:v%{public}d → v%{public}d', from, FB_DB_VERSION);
// ---- v1 → v2:名册支持 ----
// ⚠️ 老库(已经装了观影那版的手机)升级时走到这里。
// v2 的改动是"persons 补 7 列 + 新增 person_roster / person_tags 两张表"。
// ⚠️ SQLite 的 `ALTER TABLE ADD COLUMN` **不支持 IF NOT EXISTS**,
// 所以这里靠"版本号只加一次"来保证不重复执行(from<2 才进来)。
// ⚠️ 新增的列都给上 NOT NULL DEFAULT '' 或允许 NULL ——
// 老数据行会被填上默认值,不会因为加列而失败。
if (from < 2) {
const addCols: string[] = [
`ALTER TABLE persons ADD COLUMN avatar_color TEXT`,
`ALTER TABLE persons ADD COLUMN email TEXT`,
`ALTER TABLE persons ADD COLUMN org TEXT`,
`ALTER TABLE persons ADD COLUMN title TEXT`,
`ALTER TABLE persons ADD COLUMN slogan TEXT`,
`ALTER TABLE persons ADD COLUMN address TEXT`,
`ALTER TABLE persons ADD COLUMN addr_city TEXT`,
];
for (const sql of addCols) {
try {
await store.executeSql(sql);
} catch (err) {
// ⚠️ 已经加过这一列就跳过(比如迁移中途失败后重跑)。
// 这里**不能**直接抛:一列已在会让整次迁移卡死、永远升不上去。
hilog.warn(DOMAIN, TAG, '加列跳过(可能已存在):%{public}s', sql);
}
}
// 两张新表用 IF NOT EXISTS,可重复执行
const newTables: string[] = [
`CREATE TABLE IF NOT EXISTS person_roster (
owner_id INTEGER NOT NULL,
person_id INTEGER NOT NULL,
family_id INTEGER NOT NULL,
group_id INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT '',
synced_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (owner_id, person_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_roster_owner ON person_roster (owner_id, sort_order)`,
`CREATE TABLE IF NOT EXISTS person_tags (
owner_id INTEGER NOT NULL,
person_id INTEGER NOT NULL,
tag TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT '',
synced_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (owner_id, person_id, tag)
)`,
`CREATE INDEX IF NOT EXISTS idx_tags_owner ON person_tags (owner_id, tag)`,
];
for (const sql of newTables) {
await store.executeSql(sql);
}
hilog.info(DOMAIN, TAG, 'v2 迁移完成(名册)');
}
// ---- v2 → v3:通用模块缓存 ----
// ⚠️ 一张"按模块名存 JSON"的通用表(理由见 FbSchema 里 module_cache 的说明)。
// 用 IF NOT EXISTS,可重复执行。
if (from < 3) {
await store.executeSql(
'CREATE TABLE IF NOT EXISTS module_cache ('
+ ' name TEXT PRIMARY KEY, payload TEXT NOT NULL DEFAULT \'\','
+ ' fetched_at INTEGER NOT NULL DEFAULT 0)');
hilog.info(DOMAIN, TAG, 'v3 迁移完成(模块缓存)');
}
// ---- v3 → v4:名册行补 `phones`(JSON 数组,主号在前)----
//
// 🔴 为什么存 JSON 而不是"号码表":
// 号码本来就是**名册同步时一起拿到的**(`/roster` 的 `phones` 字段),
// 单独建表反而要多一次 join、多一套写入逻辑;而这里只需要"整组读出来"。
// ⚠️ 与"标签用 person_tags 表"不同:标签要**跨人聚合**(标签云)、
// 必须能 GROUP BY,所以它值得一张表;电话不需要聚合。
if (from < 4) {
await store.executeSql(
"ALTER TABLE person_roster ADD COLUMN phones TEXT NOT NULL DEFAULT '[]'");
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 迁移完成(增量水位 + 附件台账)');
}
// ---- v5 → v6:修复 `person_roster.phones` 缺失(2026-10-18 真机 bug)----
//
// 🔴🔴 症状:**全新安装**后名册永远是空的,日志里
// `table person_roster has no column named phones`(SQLite errno 2)。
//
// 根因:`phones` 当初只加在 v3→v4 的 ALTER 里,**没进 `FB_SCHEMA_SQL`**(已补)。
// 而全新安装的库 `store.version` 直接就等于 `FB_DB_VERSION`,
// `migrate()` 第一行 `from >= FB_DB_VERSION` 立刻 return
// ⇒ 那句 ALTER 对新装用户**永远不执行** ⇒ 缺列 ⇒ 名册整批写入失败。
// ("老库升级"那条路是对的,所以只有新装/卸载重装会现形。)
//
// ⚠️ 为什么不能只靠"补 FB_SCHEMA_SQL":那**只对以后的新装**有效,
// 对"已经装坏了的库"(version=5 但缺列)没用 —— 它们的 version 已经到位,
// 不会再进任何 `from < N` 分支。⇒ 必须再来一次带版本号的 ALTER 把它们捞回来。
//
// ⚠️ 用 `PRAGMA table_info` 判存在性(SQLite 的 ALTER 不支持 IF NOT EXISTS):
// 直接 ALTER 在"列已存在"的老库上会抛异常 ⇒ 整次迁移卡死、永远升不上来。
// (同 v2 那段"加列跳过"的思路,但这里用**主动探测**更可靠:
// 不依赖异常控制流,也不会把别的原因造成的失败一起吞掉。)
if (from < 6) {
const has: boolean = await this.columnExists(store, 'person_roster', 'phones');
if (has) {
hilog.info(DOMAIN, TAG, 'v6 迁移:person_roster.phones 已在(跳过 ALTER)');
} else {
await store.executeSql(
"ALTER TABLE person_roster ADD COLUMN phones TEXT NOT NULL DEFAULT '[]'");
hilog.info(DOMAIN, TAG, 'v6 迁移完成:补上 person_roster.phones');
}
}
store.version = FB_DB_VERSION;
}
/**
* 表里有没有这一列(`PRAGMA table_info`)。
*
* ⚠️ 用它而不是"直接 ALTER + catch 异常":异常控制流会把
* **真正的原因**(比如库损坏、磁盘满)一起吞成"大概已经加过了",
* 然后迁移带着坏库继续往下走 —— 那比直接失败更难查。
*/
private async columnExists(store: relationalStore.RdbStore, table: string,
column: string): Promise<boolean> {
const rs: relationalStore.ResultSet =
await store.querySql('PRAGMA table_info(' + table + ')');
try {
while (rs.goToNextRow()) {
// `PRAGMA table_info` 的第 2 列(下标 1)是列名
if (rs.getString(1) === column) {
return true;
}
}
return false;
} finally {
rs.close();
}
}
/** 取已打开的 store;未打开时报错(而不是静默返回 null,避免空指针难查) */
require(): relationalStore.RdbStore {
if (this.store === null) {
throw new Error('本地库还没打开,请先 await FbDb.inst().open(ctx)');
}
return this.store;
}
/** 是否已就绪 */
isReady(): boolean {
return this.store !== null;
}
/** 打开失败的原因('' = 没失败);**诊断用**,见 lastError 的说明 */
errorText(): string {
return this.lastError;
}
/** 打开进度到哪一步;**诊断用** */
stageText(): string {
return this.lastStage;
}
/**
* 执行查询并返回对象数组。
*
* ⚠️ 用 `querySql` 直接写 SQL 而不是 `RdbPredicates`:
* 本项目的查询都是"按 family_id + updated_at 增量拉"这类**带条件的复杂查询**,
* Predicates 表达"IN (子查询)"之类很别扭;而 SQL 是服务端同步过来的原文,
* 直接对照排查更方便。
* ⚠️ 所有外部值一律走 `?` 占位(下方 `args`),**不做字符串拼接** —— 防注入。
*/
async queryRows(sql: string, args: relationalStore.ValueType[] = []): Promise<FbRow[]> {
const store: relationalStore.RdbStore = this.require();
const rs: relationalStore.ResultSet = await store.querySql(sql, args);
const rows: FbRow[] = [];
try {
while (rs.goToNextRow()) {
rows.push(FbRow.fromResultSet(rs));
}
} finally {
rs.close(); // ⚠️ 必须关;不关会泄漏游标,连续查询会报错
}
return rows;
}
/** 执行一条查询,返回第一行(没有则 null) */
async queryOne(sql: string, args: relationalStore.ValueType[] = []): Promise<FbRow | null> {
const rows: FbRow[] = await this.queryRows(sql, args);
return rows.length > 0 ? rows[0] : null;
}
/** 执行写语句(INSERT/UPDATE/DELETE 均可) */
async exec(sql: string, args: relationalStore.ValueType[] = []): Promise<void> {
await this.require().executeSql(sql, args);
}
/** 查询单个数值(COUNT/MAX 之类) */
/**
* 查询单个数值(COUNT / MAX 之类)。
*
* ⚠️ 必须走 `FbRow.num()` 而不是 `typeof v === 'number' ? v : 0`:
* SQLite 的 `COUNT(*)` 经 `getValue()` 取回来可能是 **bigint**
* (或字符串),只认 `number` 会把**真实结果错判成 0** ——
* 实测踩过:库里明明有 200 条,自检却报 `ev0`,
* 一度误判成"数据没写进去",白查了很久。
* ⇒ 交给 `num()` 统一做类型收敛。
*/
async scalar(sql: string, args: relationalStore.ValueType[] = []): Promise<number> {
const row: FbRow | null = await this.queryOne(sql, args);
if (row === null) {
return 0;
}
const keys: string[] = row.keys();
if (keys.length === 0) {
return 0;
}
return row.num(keys[0], 0);
}
/**
* 在事务里跑一段操作(**用 `createTransaction` 的对象式 API**)。
*
* 🔴🔴 这里踩过一个很深的坑,务必记住(完整排查过程见文末):
* · 老的 `store.beginTransaction()` + `store.commit()`(无参三件套)
* 在中间有 `await` 时会**丢事务上下文** ⇒
* "数据看似写进去了(同连接能查到)、`COUNT(*)` 却是 0、
* 且 Promise 永不返回"。
* · 换成 `createTransaction()` 拿到**事务对象**后,
* 在它上面用 `batchInsertSync`(同步版)仍然**卡住**
* (实测停在"4/6 组装数据"→写入之间,几分钟不返回)。
* ⇒ 最终可行组合:**`createTransaction()` 对象 + 在事务对象上 `await` 异步版**
* (`batchInsert` / `execute`)。事务上下文由**对象**持有,
* 所以这里的 `await` 不会像老三件套那样丢上下文。
*
* ⚠️ 回调必须是 `async` 且**逐个 await 完成后再返回** ——
* 回调返回后才会 `commit()`,中途还有未完成的写入就会提交不完整。
*/
async transaction(work: (tx: relationalStore.Transaction) => Promise<void>): Promise<void> {
const store: relationalStore.RdbStore = this.require();
const tx: relationalStore.Transaction = await store.createTransaction();
try {
await work(tx);
await tx.commit();
} catch (err) {
// ⚠️ 回滚也 await;失败时不往上抛(避免掩盖原始错误)
try {
await tx.rollback();
} catch (rollErr) {
hilog.error(DOMAIN, TAG, '回滚也失败了:%{public}s', JSON.stringify(rollErr));
}
hilog.error(DOMAIN, TAG, '事务回滚:%{public}s', JSON.stringify(err));
throw err as Error;
}
}
/** 关闭本地库(一般只在退出应用时调用) */
async close(): Promise<void> {
if (this.store !== null) {
await this.store.close();
this.store = null;
this.openPromise = null;
}
}
/**
* 🔴 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;
static inst(): FbDb {
if (FbDb.instance === null) {
FbDb.instance = new FbDb();
}
return FbDb.instance;
}
}
/**
* 一行查询结果(列名 → 值)。
*
* ⚠️ 为什么不用 `Record<string, relationalStore.ValueType>` 直接返回:
* ResultSet 的列值要**逐个按列名取**(`getString` / `getLong` / `isColumnNull`),
* 直接塞进 Record 会丢掉"这一列是否为 NULL"的信息
* (SQLite 里 NULL 与空串是两回事,把 NULL 变成 '' 会让"有没有填备注"判错)。
* 这里用一个小包装类把 ResultSet 的语义完整带出来。
*/
export class FbRow {
private data: Map<string, relationalStore.ValueType> = new Map<string, relationalStore.ValueType>();
private nulls: Set<string> = new Set<string>();
/** 从 ResultSet 当前行读全部列 */
static fromResultSet(rs: relationalStore.ResultSet): FbRow {
const row: FbRow = new FbRow();
const names: string[] = rs.getColumnNames();
for (const name of names) {
const idx: number = rs.getColumnIndex(name);
if (rs.isColumnNull(idx)) {
row.nulls.add(name); // 记住"这列是 NULL",不是空值
row.data.set(name, '');
} else {
// ⚠️ 用 `getValue(idx)` 而不是 `getColumnType(idx)` 再分支:
// · `getColumnType` 返回的是 **Promise**(异步!),在同步循环里用会拿到
// Promise 对象、所有比较都成立不了(第一版就这么错的);
// · 就算换成 `getColumnTypeSync`,也还是"先问类型再取值"两步。
// `getValue` 直接按列的真实类型返回 `ValueType`,一步到位、不丢类型。
row.data.set(name, rs.getValue(idx));
}
}
return row;
}
keys(): string[] {
const out: string[] = [];
this.data.forEach((_v: relationalStore.ValueType, k: string) => {
out.push(k);
});
return out;
}
/** 该列是否为 NULL */
isNull(name: string): boolean {
return this.nulls.has(name);
}
get(name: string): relationalStore.ValueType {
const v: relationalStore.ValueType | undefined = this.data.get(name);
return v !== undefined ? v : '';
}
/** 取字符串;NULL 会返回 null(与"空串"区分开) */
str(name: string): string | null {
if (this.isNull(name)) {
return null;
}
const v: relationalStore.ValueType = this.get(name);
if (typeof v === 'string') {
return v;
}
if (typeof v === 'number' || typeof v === 'bigint' || typeof v === 'boolean') {
return String(v); // ⚠️ ValueType 含 bigint/boolean,都要能转
}
return null; // Uint8Array / Asset 之类不走字符串通道
}
/** 取字符串,NULL 折成空串(表单显示用) */
strOr(name: string, fallback: string): string {
const v: string | null = this.str(name);
return v !== null ? v : fallback;
}
/** 取整数;NULL 折成 fallback */
num(name: string, fallback: number): number {
if (this.isNull(name)) {
return fallback;
}
const v: relationalStore.ValueType = this.get(name);
if (typeof v === 'number') {
return v;
}
if (typeof v === 'bigint') {
return Number(v);
}
if (typeof v === 'string') {
const n: number = Number(v);
return Number.isNaN(n) ? fallback : n;
}
if (typeof v === 'boolean') {
return v ? 1 : 0;
}
return fallback;
}
/** 取布尔(SQLite 用 0/1 存) */
bool(name: string): boolean {
return this.num(name, 0) !== 0;
}
}