Files
FamilyBook_harmonyos/entry/src/main/ets/data/FbSchema.ets
T

297 lines
13 KiB
Plaintext
Raw Normal View History

/**
* 家书 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 = 4;
/**
* 建表语句。
*
* ⚠️ 只对**首装**(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,
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)`,
// ---------- 名册(v2 新增)----------
// 🔴 为什么名册需要**自己**的表,而不是直接用 `persons`:
// `persons` 是"全家的通讯录底账"(本家 800+ 人);
// 而名册是「**我**挑出来的那一小撮」—— 我加的人 + 别人分享给我的。
// 服务端用 `person_roster(family_id, owner_id, person_id)` 表达这层"谁的",
// 端上必须同样有一张,否则分不清"这人是我名册里的,还是只是全家的人"。
// ⚠️ 主键用 (owner_id, person_id):一个人在一个人的名册里只会出现一次。
`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,
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
)`,
];
/** 建表语句分组说明(供日志/排查用) */
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: ['sync_state', 'pending_ops']
}
];
/** 需要参与同步的业务表(与服务端 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,
// ⚠️ 不开 encrypt:库内是家庭日程/观影记录这类普通数据;
// 真要加密应走"整库加密 + 密钥进 KeyStore",不是在这里开个开关了事。
encrypt: false
};