## 用户指出的问题
「这个数据量很大,不是应该每次同步完了之后解包放在一个数据库里吗?
然后点开日程,不就是把今天的日程从数据库里查出来吗?
这个应该速度非常快才对呀!怎么能每次打开都要去读所有的数据呢?」
用户说得完全对。这是**架构错误**,上一轮我只做了算法层的局部优化
(把 O(n²) 改成 O(n)),没动"数据怎么存、怎么取"这个根本问题。
## 改前 vs 改后
改前:
load() → module_cache 表读 **9.3MB 字符串**
→ JSON.parse **28628 个对象**
→ 全塞进 this.allEvents 内存数组
渲染 → 在这 28628 条上遍历
`module_cache` 的设计注释本来写着「解析一遍毫秒级,可接受」——
那是按小数据量假设写的,日程涨到 2.8 万条后这个假设就破了。
改后:
load() → ① `WHERE date BETWEEN ? AND ?`(走 idx_cal_date)→ 立刻渲染
② 后台拉全量 → 批量入库 → 重查当前范围
渲染 → 只在**当前范围**那几十到几百条上遍历
## 改动
### FbSchema / FbDb:新增 cal_events 实体表
字段对齐 FbCalEvent(12 个)+ uid + fetched_at,三个索引:
- `idx_cal_date` —— 按日期查(最高频)
- `idx_cal_date_kind` —— 农历/节日按 kind 过滤
- `idx_cal_uid` —— 同步去重
⚠️ 主键不用服务端 `ref_id`:衍生条目(农历/节气/纪念日)的 ref_id **恒为 0**,
几万条全撞主键。用自增 rowid + (cal_id, uid) 唯一索引。
uid 由 kind|calId|evId|date|time|title哈希 组成。
⚠️ **建表语句与 migrate 两处都改了**,列名逐一比对一致
(这个坑本项目已踩 5 次,加了静态守卫钉住)。
### FbCalStore(新建):本地库读写
- `replaceAll(rows, stamp)` —— 事务内先 DELETE 再分批写(20 行/批,照
FbMovieRepo 的实测结论,200 行会卡死)。整批替换而非逐条 upsert,
否则服务端已删的条目会永远留在本地。
- `between(from,to)` / `ofDay(iso)` —— 页面真正用的查询
- `lunarBetween` —— 月视图 42 个格子只要农历文字,单独查比取全量再过滤快
- `count()` / `fetchedAt()` —— 判空与"数据新不新"
⚠️ 命名用 `FbCalStore` 而不是 `FbCalRepo`:后者已存在,是**接口读写**层
(create/update/remove/fetchBooks)。我第一版误覆盖了它,编译报 8 处错误后
从 git 恢复,改名为 Store 区分「本地库」和「接口」。
### FbCalendarPage
- `allEvents` → **`rangeEvents`**(改名强制提醒:这里不再是全量)
- `rangeOfView()` —— 算当前视图需要的日期区间
- `loadFromLocal()` —— 先查本地,有数据立刻渲染
- `persistAndRefresh()` —— 入库完成后重查,界面跟上最新数据
- `reloadRange()` + `@Monitor('showYear','showMonth','mode')` —— 翻月/切视图按需重查
- `rangeSeq` —— 防抖,避免旧结果覆盖新结果
## 验证
### 编译
`build_hap debug` → BUILD SUCCESSFUL
### 静态校验(34 项全过)
`_tooling/check_cal_db.js`,含最关键的两项:
- 建表与迁移**列名逐一相同**(15 列全对上)
- `load()` 里本地查询确实在远程同步**之前**
### 未验证
**真机实测未做**(设备未连接)。需要确认:
- 老库升级(v7→v8)是否顺利建表
- 首次进入(库空)要等全量拉取,之后进入应该秒开
- 翻月是否立刻有数据
## 过程中的一个失误
第一版把新仓储命名为 `FbCalRepo`,**覆盖了已存在的同名文件**。
编译立刻报 8 处 "has no exported member / does not exist on type"。
从 git 恢复原文件后改名 `FbCalStore`,避免了两层职责混在一个类里。
教训:新建文件前先查同名是否存在(`git status` 没报是因为该文件本就在版控里,
我 `cp` 上去把它改成"已修改"状态,看着像正常改动)。
424 lines
21 KiB
Plaintext
424 lines
21 KiB
Plaintext
/**
|
||
* 家书 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 = 8;
|
||
|
||
/**
|
||
* 建表语句。
|
||
*
|
||
* ⚠️ 只对**首装**(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)`,
|
||
];
|
||
|
||
/** 建表语句分组说明(供日志/排查用) */
|
||
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: ['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
|
||
}; |