修「名册永远是空的」: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:
2026-10-18 20:40:30 +08:00
parent f020a671c0
commit 77b494a274
3 changed files with 106 additions and 8 deletions
@@ -386,14 +386,41 @@ export struct FbPeoplePage {
this.syncNote = '未登录,显示的是本地数据';
return;
}
const res: FbRosterSyncResult = await this.repo.sync(token);
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '名册同步失败:%{public}s', res.error);
this.syncNote = '同步失败:' + res.error;
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);
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '名册同步失败:%{public}s', res.error);
this.syncNote = '同步失败:' + res.error;
return;
}
this.syncNote = '';
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;
}
this.syncNote = '';
await this.reloadFromLocal();
}
/**