v0.7.0 接上真实服务器:登录 / 设备会话 / 全量同步与各模块真实数据
从「演示数据壳子」走到「真的能连家里的服务器用」。
登录(用户 2026-10-17「完整的登录功能」)
· 首次启动先填服务器地址 → /api/v1/ping 自报门户(认得出"这地址上真是家书")
· 手机号或用户名 + 密码 → 拿令牌;失败提示统一,不泄露账号是否存在
· FbDeviceStore:首次启动生成并持久化设备 UUID(三方应用拿不到 UDID),
设备名取机型(marketName),并上报 platform / app_version
设备会话(v57,用户 2026-10-17)
· 一把令牌 = 一台设备的一条会话;每次启动打一次 /me(有网就验)
· 两种锁分开:need_login(401/被踢 → 清令牌)与 offline_expired
(离线超 24h → 只锁界面、不清数据、联网自动恢复)——
混成一种就会违背用户要的"一直联网就不用每天输密码"
· 运行期任何请求 401 → 网络层全局回调立刻锁定跳登录页(FbHttp 的 authProbe
让登录/校验类请求跳过,避免"两个地方同时跳登录页")
· 退出登录:先清本地令牌(主防线),再尽力通知服务端
· 设置页显示版本号与「这台设备」
数据层
· FbDb / FbSchema / FbParse / 各 Repo(日程 / 名册 / 观影 / 模块)+ FbSyncAll
· 首页改为服务端 /home(农历/节气/纪念日/生日/天气/统计/往年的今天)
· 日程页用 FbDayEventPanel,与网页端显示方式/红线/并排三处一致
验证:ArkTS 静态守卫 231/0;hvigor 编译 BUILD SUCCESSFUL(entry-default-signed.hap)。
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 家书 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
|
||||
};
|
||||
Reference in New Issue
Block a user