修「名册永远是空的」: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。
This commit is contained in:
@@ -312,9 +312,63 @@ export class FbDb {
|
|||||||
hilog.info(DOMAIN, TAG, 'v5 迁移完成(增量水位 + 附件台账)');
|
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;
|
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,避免空指针难查) */
|
/** 取已打开的 store;未打开时报错(而不是静默返回 null,避免空指针难查) */
|
||||||
require(): relationalStore.RdbStore {
|
require(): relationalStore.RdbStore {
|
||||||
if (this.store === null) {
|
if (this.store === null) {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const FB_DB_NAME: string = 'familybook.db';
|
|||||||
* 本地库**不能像服务端那样"每次启动都全量 ALTER 一遍"** ——
|
* 本地库**不能像服务端那样"每次启动都全量 ALTER 一遍"** ——
|
||||||
* 那是拿用户的手机当试验田。必须按版本逐步迁移。
|
* 那是拿用户的手机当试验田。必须按版本逐步迁移。
|
||||||
*/
|
*/
|
||||||
export const FB_DB_VERSION: number = 5;
|
export const FB_DB_VERSION: number = 6;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 建表语句。
|
* 建表语句。
|
||||||
@@ -251,6 +251,22 @@ export const FB_SCHEMA_SQL: string[] = [
|
|||||||
// 服务端用 `person_roster(family_id, owner_id, person_id)` 表达这层"谁的",
|
// 服务端用 `person_roster(family_id, owner_id, person_id)` 表达这层"谁的",
|
||||||
// 端上必须同样有一张,否则分不清"这人是我名册里的,还是只是全家的人"。
|
// 端上必须同样有一张,否则分不清"这人是我名册里的,还是只是全家的人"。
|
||||||
// ⚠️ 主键用 (owner_id, person_id):一个人在一个人的名册里只会出现一次。
|
// ⚠️ 主键用 (owner_id, person_id):一个人在一个人的名册里只会出现一次。
|
||||||
|
//
|
||||||
|
// 🔴🔴 `phones` **必须**在首装语句里(2026-10-18 真机踩出来的真 bug)。
|
||||||
|
// 它当初只加在 `FbDb.migrate()` 的 v3→v4 那步 ALTER 里,**没进这张建表语句**。
|
||||||
|
// 后果:**全新安装**(卸载重装、新手机)的库 `store.version` 直接就是
|
||||||
|
// `FB_DB_VERSION`(5) ⇒ `migrate()` 第一行 `from >= FB_DB_VERSION` 直接 return
|
||||||
|
// ⇒ 那句 ALTER **永远不执行** ⇒ 表里没有 `phones` 列
|
||||||
|
// ⇒ 名册同步写 `person_roster` 时 SQLite 报
|
||||||
|
// `table person_roster has no column named phones`
|
||||||
|
// ⇒ 整批名册写入失败 ⇒ **名册页永远是空的**(而"老用户升级"那条路反倒没问题,
|
||||||
|
// 所以这个 bug 只在全新安装上现形 —— 用户正好卸载重装了,才暴露出来)。
|
||||||
|
//
|
||||||
|
// ⇒ 纪律:**建表语句是"全新安装的最终形态",必须包含所有历史 ALTER 加过的列**。
|
||||||
|
// 加列时两处都要改:`FB_SCHEMA_SQL`(新装)+ `migrate()`(老库)。
|
||||||
|
// 只改 migrate() 等于"老用户有、新用户没有";只改这里则"新用户有、老用户没有"。
|
||||||
|
// 代码里已加静态守卫钉住这一条(见 `_fb_arkts_guard.js` 的
|
||||||
|
// 「建表 vs 迁移列一致性」一节)。
|
||||||
`CREATE TABLE IF NOT EXISTS person_roster (
|
`CREATE TABLE IF NOT EXISTS person_roster (
|
||||||
owner_id INTEGER NOT NULL,
|
owner_id INTEGER NOT NULL,
|
||||||
person_id INTEGER NOT NULL,
|
person_id INTEGER NOT NULL,
|
||||||
@@ -260,6 +276,7 @@ export const FB_SCHEMA_SQL: string[] = [
|
|||||||
source TEXT NOT NULL DEFAULT '',
|
source TEXT NOT NULL DEFAULT '',
|
||||||
updated_at TEXT NOT NULL DEFAULT '',
|
updated_at TEXT NOT NULL DEFAULT '',
|
||||||
synced_at INTEGER NOT NULL DEFAULT 0,
|
synced_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
phones TEXT NOT NULL DEFAULT '[]',
|
||||||
PRIMARY KEY (owner_id, person_id)
|
PRIMARY KEY (owner_id, person_id)
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_roster_owner ON person_roster (owner_id, sort_order)`,
|
`CREATE INDEX IF NOT EXISTS idx_roster_owner ON person_roster (owner_id, sort_order)`,
|
||||||
|
|||||||
@@ -386,6 +386,24 @@ export struct FbPeoplePage {
|
|||||||
this.syncNote = '未登录,显示的是本地数据';
|
this.syncNote = '未登录,显示的是本地数据';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* 🔴🔴 必须 try/catch(2026-10-18 真机踩出来的真 bug)。
|
||||||
|
*
|
||||||
|
* 原来这里直接把 `repo.sync()` 的返回值当"唯一失败通道"——
|
||||||
|
* 但 `sync()` 里的**落库**(batchInsert / executeSql)抛异常时,
|
||||||
|
* 异常会从 `syncInBackground()` 一路冒到调用方(而调用方是 `bootstrap()`,
|
||||||
|
* 之后没有任何 catch)⇒ **整个后台同步静默死掉**:
|
||||||
|
* · `syncNote` 仍是 ''(看着像"同步成功")
|
||||||
|
* · 列表没有任何数据
|
||||||
|
* · 用户看到的就是一句「登录后会从服务器拉取你名册里的人」
|
||||||
|
* ——**一个已经登录的人,被提示去登录**,完全猜不到真实原因。
|
||||||
|
* 实测这个 bug 藏了好几轮:真机日志里明明有
|
||||||
|
* `table person_roster has no column named phones`,
|
||||||
|
* 界面上却一句提示都没有。
|
||||||
|
*
|
||||||
|
* ⇒ 纪律:**后台任务里的异常必须就地落成"用户看得见的一句话"**,
|
||||||
|
* 不能靠"调用方会 catch"(调用方往往是 fire-and-forget 的)。
|
||||||
|
*/
|
||||||
|
try {
|
||||||
const res: FbRosterSyncResult = await this.repo.sync(token);
|
const res: FbRosterSyncResult = await this.repo.sync(token);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
hilog.warn(DOMAIN, TAG, '名册同步失败:%{public}s', res.error);
|
hilog.warn(DOMAIN, TAG, '名册同步失败:%{public}s', res.error);
|
||||||
@@ -394,6 +412,15 @@ export struct FbPeoplePage {
|
|||||||
}
|
}
|
||||||
this.syncNote = '';
|
this.syncNote = '';
|
||||||
await this.reloadFromLocal();
|
await this.reloadFromLocal();
|
||||||
|
} catch (err) {
|
||||||
|
const e: BusinessError = err as BusinessError;
|
||||||
|
const msg: string = e.message !== undefined ? e.message : JSON.stringify(err);
|
||||||
|
hilog.error(DOMAIN, TAG, '名册同步抛异常(落库失败?):%{public}s', msg);
|
||||||
|
// ⚠️ 文案要**给出可行动的信息**:本地库写不进去,建议重装/清数据 ——
|
||||||
|
// 只说"同步失败"用户只能反复下拉,还是不知道怎么办。
|
||||||
|
this.syncNote = '名册写入本地库失败:' + msg;
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user