/** * 家书 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 | null = null; /** 正在清库的 Promise(`open` 要等它,见 open 里的说明) */ private wipePromise: Promise | null = null; /** * 打开(必要时创建)本地库并保证结构就绪。 * * @param ctx 应用上下文(`getContext()` 得到的能力上下文, * ⚠️ 不能传 `undefined`——RDB 要靠它定位沙箱目录) */ open(ctx: common.Context): Promise { // 🔴 v58:正在清库时**不能**打开(会打开一个正在被删除的库,表现为各种诡异错误)。 // 等它清完再走一遍 open —— `wipePromise` 在 finally 里清空,所以只会递归一次。 if (this.wipePromise !== null) { return this.wipePromise.then((): Promise => this.open(ctx)); } if (this.store !== null) { return Promise.resolve(this.store); } if (this.openPromise !== null) { return this.openPromise; // 已有人在开,跟着等同一个 } this.openPromise = this.doOpen(ctx); return this.openPromise; } /** 真正的打开逻辑 */ private async doOpen(ctx: common.Context): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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): Promise { 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 { 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 { // 并发去重:两处同时触发(比如"被踢"和"用户自己点了退出")只清一次 if (this.wipePromise !== null) { return this.wipePromise; } const p: Promise = this.doWipe(ctx); this.wipePromise = p; try { return await p; } finally { this.wipePromise = null; } } /** `wipe` 的实际动作(拆出来只为了包并发去重,逻辑见上) */ private async doWipe(ctx: common.Context): Promise { // ① 先关句柄(必须) try { await this.close(); } catch (err) { hilog.warn(DOMAIN, TAG, '清库前关闭失败(继续尝试删除):%{public}s', JSON.stringify(err)); } // ② 删库文件(密钥随之失去意义) let ok: boolean = true; try { await relationalStore.deleteRdbStore(ctx, FB_DB_NAME); hilog.info(DOMAIN, TAG, '本地库已删除'); } catch (err) { const e: BusinessError = err as BusinessError; ok = false; this.lastError = '清空本地库失败:' + (e.message !== undefined ? e.message : JSON.stringify(err)); hilog.error(DOMAIN, TAG, '清空本地库失败:%{public}s', this.lastError); } // ③ 重置状态:下次 open() 会重新建库(新库 = 空的,等重新同步) this.store = null; this.openPromise = null; this.lastStage = ok ? '[wipe] 已清空' : '[wipe] 清空失败'; return ok; } /** * 本地库现在是不是"加密的"。 * * ⚠️ 这是**推断**,不是权威查询:ArkData 没有"这个库加密了吗"的公开接口。 * 判据是"打开过程里没有走过降级分支"(`lastError` 里没有降级记录)。 * 用途只有一个:设置页的「诊断」如实告诉用户"你的本机数据是加密的/不是"。 * 别拿它做安全决策。 */ isEncrypted(): boolean { return this.isReady() && !this.degraded; } // ---- 单例 ---- private static instance: FbDb | null = null; static inst(): FbDb { if (FbDb.instance === null) { FbDb.instance = new FbDb(); } return FbDb.instance; } } /** * 一行查询结果(列名 → 值)。 * * ⚠️ 为什么不用 `Record` 直接返回: * ResultSet 的列值要**逐个按列名取**(`getString` / `getLong` / `isColumnNull`), * 直接塞进 Record 会丢掉"这一列是否为 NULL"的信息 * (SQLite 里 NULL 与空串是两回事,把 NULL 变成 '' 会让"有没有填备注"判错)。 * 这里用一个小包装类把 ResultSet 的语义完整带出来。 */ export class FbRow { private data: Map = new Map(); private nulls: Set = new Set(); /** 从 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; } }