/** * 家书 FamilyBook · 本地数据库结构(鸿蒙端) * * 设计立场:**本地优先(local-first)**。 * · 本地库是 UI 的**唯一数据源**,界面永远不直接读网络; * · 网络只在后台把服务端数据"补进来"、把本地改动"推出去"; * · 断网时所有功能照常可用(因为数据本来就在本地)。 * * 🔴 建表原则:**按实体建表,不按模块建表**。 * 「观影」看起来是一个模块,但服务端它是 6 张表组合出的视图: * events(kind='movie') + movie_details + cinemas + movie_reviews * + event_attendees + event_photos * ⇒ 本地也照**实体**存:`events` 一张、`movie_details` 一张…… * 观影页要的数据用 VIEW(或 JOIN 查询)拼出来。 * ⚠️ 若按模块建表(一张 movies 表塞所有字段),会出现: * 同一条日程在「日程模块」和「观影模块」各存一份、互相打架。 * 这是本地优先最容易踩的坑。 * * 🔴 同步字段(每张业务表都有,与服务端 v33 结构对齐): * · `updated_at` —— 服务端最后修改时间(**由服务端 MySQL 自动维护**); * · `deleted_at` —— 软删除墓碑(NULL = 未删); * · `synced_at` —— **本地专有**:上次把它同步下来的时间, * 用来判断"这条是否已被本地改动覆盖"。 * · `dirty` —— **本地专有**:1 = 有未推送的本地改动。 * ⚠️ 后两个字段是本地专有的,**不属于服务端结构**,回推时不发送。 */ import { relationalStore } from '@kit.ArkData'; /** 本地库文件名(放在应用沙箱的 databases 目录下) */ export const FB_DB_NAME: string = 'familybook.db'; /** * 本地库版本号。 * * ⚠️ 每次改表结构都要 +1,并在 `migrate()` 里补上对应的 ALTER。 * 本地库**不能像服务端那样"每次启动都全量 ALTER 一遍"** —— * 那是拿用户的手机当试验田。必须按版本逐步迁移。 */ export const FB_DB_VERSION: number = 9; /** * 建表语句。 * * ⚠️ 只对**首装**(onCreate)执行。老库升级走 `migrate()`。 */ export const FB_SCHEMA_SQL: string[] = [ // ---------- 观影(4 张实体表)---------- `CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY, family_id INTEGER NOT NULL, creator_id INTEGER, kind TEXT NOT NULL, title TEXT NOT NULL, starts_at TEXT, ends_at TEXT, location TEXT, note TEXT, source_ref TEXT, created_at TEXT, updated_at TEXT NOT NULL DEFAULT '', deleted_at TEXT, -- ⚠️ 下面两列是**派生/聚合值**(服务端 Movie::attachStats 算出来的), -- 不是 events 表的原生列。放这里是为了"观影列表一次查询出结果", -- 免得每渲染一屏都去 GROUP BY movie_reviews。 -- ⚠️ 它们**会被同步覆盖**,所以本地改动不能写这两列。 review_count INTEGER NOT NULL DEFAULT 0, review_avg REAL NOT NULL DEFAULT 0, synced_at INTEGER NOT NULL DEFAULT 0, dirty INTEGER NOT NULL DEFAULT 0 )`, // 增量拉取的主力索引(与服务端 idx_events_sync 同语义) `CREATE INDEX IF NOT EXISTS idx_events_sync ON events (family_id, updated_at)`, `CREATE TABLE IF NOT EXISTS movie_details ( event_id INTEGER PRIMARY KEY, info TEXT, poster_key TEXT, ticket_key TEXT, cinema_id INTEGER, douban_meta TEXT, updated_at TEXT NOT NULL DEFAULT '', deleted_at TEXT, synced_at INTEGER NOT NULL DEFAULT 0, dirty INTEGER NOT NULL DEFAULT 0 )`, `CREATE TABLE IF NOT EXISTS cinemas ( id INTEGER PRIMARY KEY, family_id INTEGER NOT NULL, place TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '', address TEXT, color TEXT NOT NULL DEFAULT '', created_at TEXT, updated_at TEXT NOT NULL DEFAULT '', deleted_at TEXT, synced_at INTEGER NOT NULL DEFAULT 0, dirty INTEGER NOT NULL DEFAULT 0 )`, `CREATE INDEX IF NOT EXISTS idx_cinemas_sync ON cinemas (family_id, updated_at)`, `CREATE TABLE IF NOT EXISTS movie_reviews ( id INTEGER PRIMARY KEY, family_id INTEGER NOT NULL, event_id INTEGER NOT NULL, person_id INTEGER NOT NULL, rating INTEGER NOT NULL DEFAULT 0, content TEXT NOT NULL DEFAULT '', feelings TEXT, created_at TEXT, updated_at TEXT NOT NULL DEFAULT '', deleted_at TEXT, synced_at INTEGER NOT NULL DEFAULT 0, dirty INTEGER NOT NULL DEFAULT 0 )`, `CREATE INDEX IF NOT EXISTS idx_movie_reviews_event ON movie_reviews (event_id)`, // ---------- 共用实体(观影页也要用)---------- `CREATE TABLE IF NOT EXISTS persons ( id INTEGER PRIMARY KEY, family_id INTEGER NOT NULL, kind TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '', nickname TEXT, gender TEXT, birthday TEXT, avatar TEXT, note TEXT, -- ⚠️ v2 新增(名册要用):这些在服务端 persons 表里本来就有, -- 只是之前只为观影的"同看人"建了最小字段集。 -- 名册页要显示单位/职务/口号/邮箱/地址/头像色,所以补齐。 -- 🔴 注意:本段是 SQL 注释、而整条建表语句是 JS 模板字符串, -- 注释里**绝不能出现反引号** —— 一个反引号就会把模板串提前闭合, -- 后续全被当成代码(报 "Cannot find name 'persons'")。 -- 这个坑本项目已踩第 4 次,写 SQL 注释时手别抖。 avatar_color TEXT, email TEXT, org TEXT, title TEXT, slogan TEXT, address TEXT, addr_city TEXT, -- 🔴 v7(v59e)新增:拼音首字母(服务端 Pinyin::initials 算好下发)。 -- 名册搜索「输入 yy 搜杨永全」全靠它;原来端上把它写死成空串 ⇒ 拼音搜索全废。 -- ⚠️ 加列时**两处都要改**:这里(全新安装)+ FbDb.migrate() 的 v6→v7 -- (老库升级)。只改一处就是"新装能搜、老库搜不到"或者反过来。 -- ⚠️ 本段是 SQL 注释、而整条建表语句是 JS 模板字符串 —— 注释里**绝不能 -- 出现反引号**(一个反引号就会把模板串提前闭合,报一堆 -- "Cannot find name Pinyin/FbDb" 这种看着毫不相干的错)。已踩第 5 次。 initials TEXT, created_at TEXT, updated_at TEXT NOT NULL DEFAULT '', deleted_at TEXT, synced_at INTEGER NOT NULL DEFAULT 0, dirty INTEGER NOT NULL DEFAULT 0 )`, `CREATE INDEX IF NOT EXISTS idx_persons_sync ON persons (family_id, updated_at)`, `CREATE TABLE IF NOT EXISTS event_attendees ( -- ⚠️ 用**复合主键**而不是自增 id: -- 接口返回的 attendees[].id 实测是 persons.id(服务端 SQL 是 -- "SELECT a.event_id, p.id, ..."),**不是**这张关联表自己的行 id -- (服务端压根没返回那个)。 -- 我们既然不掌握行 id,就用「事件 + 人」这个业务唯一键做主键 —— -- 一个事件里同一个人本来就只会出现一次,语义正确且幂等。 -- -- 🔴 注意:本行注释里**不能出现反引号** —— 整个建表语句是 JS 模板字符串, -- 注释里一个反引号就会把它**提前闭合**,后面全变成代码、编译直接报 -- "Cannot find name 'SELECT'"(这个坑刚踩过)。 event_id INTEGER NOT NULL, person_id INTEGER NOT NULL, position INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT '', deleted_at TEXT, synced_at INTEGER NOT NULL DEFAULT 0, dirty INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (event_id, person_id) )`, `CREATE INDEX IF NOT EXISTS idx_attendees_event ON event_attendees (event_id)`, // ---------- 同步元数据(本地专有,不对应任何服务端表)---------- /** * 每张表各存一条"上次同步到哪"的水位线。 * * 🔴 为什么**按表**而不是全局一个水位: * 各表是分别拉取的。若共用一个全局水位,A 表拉成功了、B 表失败了, * 水位推进之后 B 表那段数据**永远不会再被拉下来**(静默丢数据)。 */ `CREATE TABLE IF NOT EXISTS sync_state ( table_name TEXT PRIMARY KEY, last_sync_at TEXT NOT NULL DEFAULT '', last_ok_at INTEGER NOT NULL DEFAULT 0 )`, /** * 待推送队列(本地改动)。 * * 🔴 为什么要有队列、而不是"改完立刻发请求": * ① 断网时改动不能丢 —— 排队等着,有网再发; * ② 用户连点几下,不该发几个请求 —— 同一行合并成一条; * ③ 请求失败要能重试 —— 记录次数,避免无限重试打爆。 */ `CREATE TABLE IF NOT EXISTS pending_ops ( id INTEGER PRIMARY KEY, table_name TEXT NOT NULL, row_id INTEGER NOT NULL, op TEXT NOT NULL, payload TEXT, attempts INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT 0 )`, // 同一行只保留最后一次操作(合并重复改动) `CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_row ON pending_ops (table_name, row_id)`, /** * 🔴 v58:模块级同步水位(增量同步用)。 * * 为什么不能复用上面的 `sync_state`: * 那张表记的是"**这张表**上次同步到哪个服务端时间",它服务于"按表增量拉"。 * 而 `changes` 接口给的是"**这个模块**的变更指纹" —— 粒度不同(一个模块 = 多张表)。 * 硬塞进同一张表会让两种语义互相覆盖,出问题时根本分不清是谁写的。 * * `fp` 是服务端指纹的**原样字符串**(`行数:最大id:最大updated_at`)。 * 端上**只做相等比较**,绝不解析它 —— 服务端换算法时端上不用改。 */ `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 )`, /** * 🔴 v59:附件缓存台账(海报 / 头像 / 观影照片 / 资源附件)。 * * 为什么要有这张表而不是"直接看文件在不在": * · 要记**失败次数**(下载失败要退避重试,不能每次启动都重撞); * · 要记**大小**(面板上显示"已缓存 12.3MB"); * · 要能在"踢设备"时**整表清空**(不该靠遍历目录猜哪些是我们下的)。 * * `path` 是沙箱内的文件名(不含目录),内容为 AES 密文(见 `FbAttachQueue`)。 * ⚠️ `key` 是**服务端相对键**(如 `posters/963dd25f.jpg`),与 `media.php?k=` 一致。 */ `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 )`, `CREATE INDEX IF NOT EXISTS idx_attach_state ON attach_cache (state, tries)`, // ---------- 名册(v2 新增)---------- // 🔴 为什么名册需要**自己**的表,而不是直接用 `persons`: // `persons` 是"全家的通讯录底账"(本家 800+ 人); // 而名册是「**我**挑出来的那一小撮」—— 我加的人 + 别人分享给我的。 // 服务端用 `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, 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, 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)`, // 标签:一个人可以有多个标签(服务端 v28 起把"分组"降级成"标签词表", // 理由:一个人既是小学同学又是初中同学,分组放不下、标签可以)。 `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)`, // ---------- 通用模块缓存(v3 新增)---------- // 🔴 为什么要这张表:日历/任务/纪念日/旅程/账本/资源/藏书/笔记/通知/首页 // 这些模块的数据**目前只需要"拉一次 + 展示"**,没有离线写入的需求。 // 为它们各建实体表(几十列 × 10 张)成本极高、收益极低; // 而完全不缓存又会让"断网打不开、每次转圈"。 // ⇒ 折中:**原样缓存接口 JSON**,UI 每次解析。 // ⚠️ 代价:拉回来就要解析一遍(10 个模块毫秒级,可接受)。 // ⚠️ 将来某模块要"离线可写"(任务打勾、账本记账)时, // **那个模块**单独拆实体表 —— 不要整体重构。 `CREATE TABLE IF NOT EXISTS module_cache ( name TEXT PRIMARY KEY, payload TEXT NOT NULL DEFAULT '', fetched_at INTEGER NOT NULL DEFAULT 0 )`, // ---------- 日程实体表(v8 新增)---------- // // 用户 2026-11-03 指出:「数据量这么大,不是应该同步完放进数据库, // 点开日程就把今天的查出来吗?怎么能每次都去读全部数据?」 // // 他说得对。原来日程走 `module_cache`(存 9.3MB JSON 原样文本), // 每次进页面都要读 9.3MB + JSON.parse 28628 个对象 + 全塞内存。 // 那张表的注释本来说「解析一遍毫秒级,可接受」—— 那是按小数据量假设写的, // 日程 2.8 万条之后这个假设就破了。 // // ⇒ 日程单独建实体表(跟 events 表给观影用的思路一致), // 同步时批量入库,页面按日期范围查。 // // ⚠️ 为什么主键不用 id: // 服务端 `ref_id` 对衍生条目(农历/节气/纪念日)恒为 0, // 几万条全是 0 会撞主键。用自增 rowid + (cal_id, uid) 唯一索引。 `CREATE TABLE IF NOT EXISTS cal_events ( row_id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT NOT NULL DEFAULT '' COMMENT '服务端条目稳定标识', ev_id INTEGER NOT NULL DEFAULT 0 COMMENT '服务端 ref_id(自定义日程才是真 id)', cal_id INTEGER NOT NULL DEFAULT 0 COMMENT '所属日历本 id;0=衍生条目', title TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '' COMMENT 'YYYY-MM-DD', time TEXT NOT NULL DEFAULT '' COMMENT 'HH:mm;全天为空', end_time TEXT NOT NULL DEFAULT '', cal_name TEXT NOT NULL DEFAULT '', color TEXT NOT NULL DEFAULT '', all_day INTEGER NOT NULL DEFAULT 0, place TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '', kind TEXT NOT NULL DEFAULT '' COMMENT 'custom / lunar / holiday / anniv …', fetched_at INTEGER NOT NULL DEFAULT 0 COMMENT '这批数据的拉取时刻(全量替换时用来防并发)' )`, // 按日期查是最高频的(「今天的日程」「本月」),必须索引 `CREATE INDEX IF NOT EXISTS idx_cal_date ON cal_events (date)`, // 农历/节日按 kind 过滤(渲染日历时区分「真日程」和「衍生条目」) `CREATE INDEX IF NOT EXISTS idx_cal_date_kind ON cal_events (date, kind)`, // 同步时按 uid 去重/更新 `CREATE UNIQUE INDEX IF NOT EXISTS idx_cal_uid ON cal_events (cal_id, uid)`, // ---------- 日历镜像快照(v9 新增)---------- // // 用户 2026-11-03:「把日历镜像到系统日历中,原因同步日历都已经说过了」。 // 原因:本应用是 CalDAV 客户端,日程不进系统日历, // 导致小艺 / 桌面日历卡片 / 手表看到的是空的 // (甚至给出"今天没有日程"的错误答案)。 // // 🔴 这张表是**防回灌的地基**:镜像会把日程写进系统日历; // 下一轮若直接读系统日历当"用户改动",就会把我们刚写的当成用户改的 // → 回写服务器 → 服务器变了 → 又镜像回去 …… 死循环。 // 记下"上次写了什么",一轮三方比对就能分清谁改的。 // // ⚠️ `bucket` 在 FamilyBook 里恒为 `family`(只有一套日程, // 不像 SyncCalendar 有多个 DAV 账户)。留这一列是为了将来按日历本拆。 `CREATE TABLE IF NOT EXISTS mirror_snapshot ( identifier TEXT PRIMARY KEY COMMENT '|[@<发生时刻>]', bucket TEXT NOT NULL DEFAULT '', uid TEXT NOT NULL DEFAULT '', sys_id INTEGER NOT NULL DEFAULT -1, fingerprint TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL DEFAULT 0 )`, `CREATE INDEX IF NOT EXISTS idx_mirror_bucket ON mirror_snapshot (bucket)`, ]; /** 建表语句分组说明(供日志/排查用) */ export interface FbTableGroup { /** 分组名(如「观影」「同步元数据」) */ group: string; /** 该组包含的表名 */ tables: string[]; } /** 本地库里的表清单(按用途分组)—— 删除库/排查时用 */ export const FB_TABLE_GROUPS: FbTableGroup[] = [ { group: '观影', tables: ['events', 'movie_details', 'cinemas', 'movie_reviews'] }, { group: '共用实体', tables: ['persons', 'event_attendees'] }, { group: '名册', tables: ['person_roster', 'person_tags'] }, { group: '日程', tables: ['cal_events'] }, { group: '系统日历镜像', tables: ['mirror_snapshot'] }, { group: '同步元数据(本地专有)', tables: ['sync_state', 'pending_ops', 'module_sync', 'attach_cache'] } ]; /** 需要参与同步的业务表(与服务端 v33 的 53 张对齐,这里先放已接入的部分) */ export const FB_SYNCED_TABLES: string[] = [ 'events', 'movie_details', 'cinemas', 'movie_reviews', 'persons', 'event_attendees', 'person_roster', 'person_tags' ]; /** 配置本地数据库(SecurityLevel 取 S1:家庭应用,不含敏感凭证) */ export const FB_STORE_CONFIG: relationalStore.StoreConfig = { name: FB_DB_NAME, securityLevel: relationalStore.SecurityLevel.S1, /** * 🔴 v58(用户 2026-10-17 定 L3:**数据在但读不出**)—— 打开整库加密。 * * 为什么这里开个开关就够了(而不像注释里原来写的那样"应该走 KeyStore"): * 鸿蒙 RDB 的 `encrypt: true` **本身就走系统密钥库** —— * 密钥由系统按「应用 + 设备」生成并托管在 HUKS 里,应用拿不到、也导不出明文。 * 所以:库文件被拷走(root / 拆机 / 备份包)**没有那把密钥就是一堆密文**。 * 这正是用户要的"捡到手机也读不出"。 * * ⚠️⚠️ **加密属性一经创建不可修改**(ArkData 的硬约束): * 已装用户手里的是**明文库**,带 `encrypt:true` 打开会直接失败。 * 处理办法在 `FbDb.doOpen()`:失败 → 删掉旧库 → 用加密参数重建 → 重新全量同步。 * 敢删是因为**本地库只是服务端数据的缓存**(见本文件头"本地优先"那段), * 用户真正的东西都在服务器上;删除只会让下一次同步变慢,不会丢数据。 * ⚠️ 但它**不等于安全擦除**:闪存上的旧块可能还有残留。真正的保证来自"新库是密文", * 不要把 `deleteRdbStore` 说成"数据已被彻底销毁"。 * * ⚠️ 万一本机不支持加密(老系统/异常),`FbDb` 会**降级用明文打开**并把原因记进 * `errorText()` —— 一个家庭应用宁可少一层保护,也不能变成打不开。 * 这条降级路径必须在设置页的「诊断」里看得见,不能静默。 */ encrypt: true };