Files
FamilyBook_harmonyos/entry/src/main/ets/data/FbDb.ets
T
laoyang 77b494a274 修「名册永远是空的」:person_roster.phones 只在迁移里加过、没进建表语句
用户 2026-10-18:「名册现在没有任何数据啊,看看咋回事。」

真机日志(唯一直接证据):
  E .../Config: table person_roster has no column named phones
  (SQLite errno 2,名册整批 batchInsert 全部失败)

根因(两条安装路径的行为分叉):
  `person_roster.phones` 当初**只加在 `FbDb.migrate()` 的 v3→v4 ALTER 里**,
  **没进 `FbSchema.FB_SCHEMA_SQL` 的建表语句**。于是:
    · 老库升级 → 走 migrate() → 执行 ALTER → 有 phones ✅
    · 全新安装 → store.version 一上来就等于 FB_DB_VERSION(5)
                 ⇒ migrate() 首行 `from >= FB_DB_VERSION` 直接 return
                 ⇒ 那句 ALTER **永远不执行** → 没有 phones ❌
  用户这次是**卸载重装**(前端流程要求),正好走的是坏的那条路。

为什么藏了这么久(两个"静默"叠加):
  ① `pageHeaderItem` 的空状态文案是「登录后会从服务器拉取你名册里的人」——
     一个**已经登录**的人被提示去登录,完全猜不到真实原因;
  ② `syncInBackground()` 里 `repo.sync()` 的**异常没人接**:
     异常从 async 函数冒出去,`bootstrap()` 之后没有任何 catch
     ⇒ 整个后台同步静默死掉,`syncNote` 还是 ''(看着像"同步成功")。

三处修复:
  ① `FB_SCHEMA_SQL` 的 person_roster 补上 `phones`(新装用户直接对);
  ② `FB_DB_VERSION` 5→6 + 新增"结构自愈"迁移:**主动探测列是否存在**
     (`PRAGMA table_info`,不靠异常控制流 —— 否则会把库损坏之类的真原因
      一起吞成"大概已经加过了"),缺则补 `ALTER`。这一条是给**已经装坏的库**
     (version=5 但缺列)用的:它们的版本号已经到位,不会再进任何 `from < N`
     分支,只能靠新版本号再捞一次。
  ③ `syncInBackground()` 包 try/catch,把异常落成**用户看得见的文案**
     (「名册写入本地库失败:<原因>」)。纪律:后台任务的异常必须就地
     变成一句话,不能指望"调用方会 catch"——调用方往往是 fire-and-forget。

真机验证(两条路径都跑了):
  · 旧库自愈:`本地库迁移:v5 → v6` → `v6 迁移完成:补上 person_roster.phones`
    → `[5] 本地库就绪,版本=6`;随后名册页**数据全出来了**
    (标签云 全部/大学同学 55/同事 40/实验室 23/合作伙伴/爸爸/家人;
      人员 爸爸/妈妈/岳父/岳母/杨永全 等),缺列报错 0 条。
  · 全新安装(清数据重来):`version=0` → 首装建表 22 条 → 直接 v6,
    迁移无事可做(正确:建表已含全部列)。

新增守卫(防这类"新装 vs 升级分叉"复发):
  `_fb_guard_schema_columns.js`(11 条)静态解析 `FB_SCHEMA_SQL` 的建表列
  与 `migrate()` 的全部 `ALTER ... ADD COLUMN`,断言**每个 ALTER 加过的列
  都必须在建表语句里**;另钉住 v6 存在性探测、版本号、后台同步 try/catch。
  `_fb_schema_falsify.py` 6 项注入 ⇒ 6/0(每项都成功打红对应断言)。
  已挂进流水线(run_guarded_suite 全量 b59b)。

顺带修 `_fb_v57_falsify.py` 的一处**过时注入锚点**:它注入的是
`allowSkip: this.app.lockReason === ''`,而该参数已按用户要求整个删除
⇒ 改成反方向注入「把 onSkip 后门加回来必须被抓」(12/0 通过)。

验证:hvigor BUILD SUCCESSFUL;_fb_arkts_guard.js 328/0;b57 12/0;新守卫 11/0。
2026-10-18 20:40:30 +08:00

676 lines
30 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 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;
}
}