2026-10-18 03:35:39 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 家书 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';
|
2026-10-18 16:01:41 +08:00
|
|
|
|
import { FB_DB_NAME, FB_DB_VERSION, FB_SCHEMA_SQL, FB_STORE_CONFIG } from './FbSchema';
|
2026-10-18 03:35:39 +08:00
|
|
|
|
|
|
|
|
|
|
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 = '';
|
2026-10-18 16:01:41 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 🔴 v58:是否**降级成了明文库**(true = 加密没起来)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 为什么要单独一个字段,而不是看 `lastError` 里有没有"降级"两个字:
|
|
|
|
|
|
* `doOpen` 成功时会清空 `lastError`(那是"这次打开失败没有"的语义),
|
|
|
|
|
|
* 拿它当"加密与否"的判据会在成功路径上立刻变成误判。
|
|
|
|
|
|
* ⇒ 两个语义分开存。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private degraded: boolean = false;
|
|
|
|
|
|
private degradedReason: string = '';
|
2026-10-18 03:35:39 +08:00
|
|
|
|
/** 打开中的 Promise(并发去重,见文件头说明) */
|
|
|
|
|
|
private openPromise: Promise<relationalStore.RdbStore> | null = null;
|
2026-10-18 16:01:41 +08:00
|
|
|
|
/** 正在清库的 Promise(`open` 要等它,见 open 里的说明) */
|
|
|
|
|
|
private wipePromise: Promise<boolean> | null = null;
|
2026-10-18 03:35:39 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 打开(必要时创建)本地库并保证结构就绪。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param ctx 应用上下文(`getContext()` 得到的能力上下文,
|
|
|
|
|
|
* ⚠️ 不能传 `undefined`——RDB 要靠它定位沙箱目录)
|
|
|
|
|
|
*/
|
|
|
|
|
|
open(ctx: common.Context): Promise<relationalStore.RdbStore> {
|
2026-10-18 16:01:41 +08:00
|
|
|
|
// 🔴 v58:正在清库时**不能**打开(会打开一个正在被删除的库,表现为各种诡异错误)。
|
|
|
|
|
|
// 等它清完再走一遍 open —— `wipePromise` 在 finally 里清空,所以只会递归一次。
|
|
|
|
|
|
if (this.wipePromise !== null) {
|
|
|
|
|
|
return this.wipePromise.then((): Promise<relationalStore.RdbStore> => this.open(ctx));
|
|
|
|
|
|
}
|
2026-10-18 03:35:39 +08:00
|
|
|
|
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);
|
2026-10-18 16:01:41 +08:00
|
|
|
|
const store: relationalStore.RdbStore = await this.openWithEncryption(ctx);
|
2026-10-18 03:35:39 +08:00
|
|
|
|
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();
|
2026-10-18 16:01:41 +08:00
|
|
|
|
// ⚠️ 降级必须**一直看得见**,不能被"这次打开成功"掩盖掉
|
|
|
|
|
|
this.lastError = this.degraded
|
|
|
|
|
|
? ('本地库未能加密(已降级为明文):' + this.degradedReason)
|
|
|
|
|
|
: '';
|
2026-10-18 03:35:39 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 16:01:41 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 🔴 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 03:35:39 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 首装建表。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 判据是 `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 迁移完成(名册电话)');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 16:01:41 +08:00
|
|
|
|
// ---- 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 迁移完成(增量水位 + 附件台账)');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 03:35:39 +08:00
|
|
|
|
store.version = FB_DB_VERSION;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 取已打开的 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 16:01:41 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 🔴 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 03:35:39 +08:00
|
|
|
|
// ---- 单例 ----
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|