diff --git a/entry/src/main/ets/data/FbDb.ets b/entry/src/main/ets/data/FbDb.ets index a969e30..e954640 100644 --- a/entry/src/main/ets/data/FbDb.ets +++ b/entry/src/main/ets/data/FbDb.ets @@ -312,9 +312,63 @@ export class FbDb { 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) { diff --git a/entry/src/main/ets/data/FbSchema.ets b/entry/src/main/ets/data/FbSchema.ets index 9518b28..3a2492c 100644 --- a/entry/src/main/ets/data/FbSchema.ets +++ b/entry/src/main/ets/data/FbSchema.ets @@ -37,7 +37,7 @@ export const FB_DB_NAME: string = 'familybook.db'; * 本地库**不能像服务端那样"每次启动都全量 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)` 表达这层"谁的", // 端上必须同样有一张,否则分不清"这人是我名册里的,还是只是全家的人"。 // ⚠️ 主键用 (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 ( owner_id INTEGER NOT NULL, person_id INTEGER NOT NULL, @@ -260,6 +276,7 @@ export const FB_SCHEMA_SQL: string[] = [ source TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '', synced_at INTEGER NOT NULL DEFAULT 0, + phones TEXT NOT NULL DEFAULT '[]', PRIMARY KEY (owner_id, person_id) )`, `CREATE INDEX IF NOT EXISTS idx_roster_owner ON person_roster (owner_id, sort_order)`, diff --git a/entry/src/main/ets/pages/people/FbPeoplePage.ets b/entry/src/main/ets/pages/people/FbPeoplePage.ets index c82b1ec..1b9a615 100644 --- a/entry/src/main/ets/pages/people/FbPeoplePage.ets +++ b/entry/src/main/ets/pages/people/FbPeoplePage.ets @@ -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(); } /**