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:
2026-10-18 03:36:04 +08:00
parent 7eaa641437
commit 776c95ba0b
64 changed files with 18297 additions and 1519 deletions
+278
View File
@@ -0,0 +1,278 @@
/**
* 家书 FamilyBook · 日程数据仓库(鸿蒙端)
*
* 对应服务端 `/api/v1/events`(增删改查)与 `/api/v1/calendar`(读全量条目 + 日历本)。
*
* 🔴 为什么日程要**单独一个 Repo**(而不是走通用的 `FbModuleRepo`):
* 通用仓库只做"拉一次、缓存 JSON、展示"——**只读**。
* 而日程是**要能改的**(用户 2026-10-15:「完全脱离电脑端……所有功能都在手机上完成」):
* 新建 / 改时间 / 改标题 / 删掉,每一项都要落到服务端。
* 写操作需要"知道哪个字段动了、失败怎么回滚",与"一次性展示"是两种东西。
* ⇒ 单独一个仓库,写操作显式处理(同观影/名册的做法)。
*
* ⚠️ 读(月历/列表)走 `/calendar`(一次拿全量条目,含节日/农历/日程,
* 由 `FbModuleRepo` 缓存);**单条日程的详情与写**走 `/events/<id>`。
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbApiOptions, FbApiResult, fbApi } from './FbHttp';
import { FbCalBook } from '../model/Types';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbCalRepo';
/** 一条日程的**完整字段**(服务端 `/events/<id>` 的形状) */
export interface FbCalEventFull {
id: number;
calendar_id: number;
title: string;
location: string;
/** `YYYY-MM-DD HH:MM:SS` */
starts_at: string;
/** `YYYY-MM-DD HH:MM:SS`(空 = 没设结束) */
ends_at: string;
all_day: boolean;
/** 备注 */
note: string;
/** 重复规则(RFC 5545,空 = 不重复) */
rrule: string;
/** 提醒(分钟,可多个;服务端存的是数组或逗号串) */
reminder_minutes: string;
status: string;
transp: boolean;
/** 创建者 person_id */
created_by: number;
}
/** 写日程时要发的字段(全部可选 —— 只传变化的) */
export interface FbCalEventInput {
calendar_id?: number;
title?: string;
location?: string;
starts_at?: string;
ends_at?: string;
all_day?: boolean;
note?: string;
/** 重复:直接给 RRULE 串(端上按 chip 拼好) */
rrule?: string;
/** 提醒分钟数(多个) */
reminder_minutes?: number[];
status?: string;
}
export class FbCalRepo {
private static _inst: FbCalRepo | null = null;
static inst(): FbCalRepo {
if (FbCalRepo._inst === null) {
FbCalRepo._inst = new FbCalRepo();
}
return FbCalRepo._inst;
}
/**
* 拉日历本清单(`GET /api/v1/calendar/books`)。
*
* 🔴🔴 这是个**真 bug 的修复**(2026-10-15 接日程改造时发现):
* 端上原来**不用这个接口**,而是从 `/calendar` 的条目里**归并**出"有哪些本",
* 并且把 `id` 编成 `seen.size + 1`(1、2、3…)——
* 那是**臆造的假 id**,与库里的 `calendars.id` 毫无关系。
* 后果:用假 id 去 `POST /events`(服务端按 `calendar_id` 找本、校验写权限)
* 必然落到错的本、或者直接 403「这本日历不能写入」⇒ **新建日程根本建不上**。
* ⇒ 老实用 `/calendar/books`(它返回真实 id / kind / perm / 是否私有)。
*
* ⚠️ `kind` 决定能不能写:`movie`/`trip`/`lunar`/`holiday` 这些是**派生本/系统本**,
* 服务端 `Calendar::isReadonlyKind` 会拒写 ⇒ 端上也要把它们标成 `canWrite=false`,
* 否则用户选了它、点保存才报错(体验很差)。
*/
async fetchBooks(token: string): Promise<FbCalBook[]> {
const res: FbApiResult = await fbApi('/api/v1/calendar/books', { token: token });
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '日历本拉取失败:%{public}s', res.error);
return [];
}
const out: FbCalBook[] = [];
try {
const rows: Record<string, Object>[] = JSON.parse(res.data) as Record<string, Object>[];
for (const r of rows) {
const kind: string = this.str(r, 'kind');
const perm: string = this.str(r, 'perm');
const b: FbCalBook = {
id: this.num(r, 'id'),
name: this.str(r, 'name'),
color: this.str(r, 'color') !== '' ? this.str(r, 'color') : '#9AA0A6',
// 派生本(观影/旅行/农历/节假日/节气/传统节日)不可写;只读共享本也不可写
canWrite: !this.isReadonlyKind(kind) && perm !== 'read',
visible: true,
shared: r['shared'] === 1 || r['shared'] === true,
derived: kind !== 'custom'
};
if (b.id > 0 && b.name !== '') {
out.push(b);
}
}
} catch (err) {
hilog.error(DOMAIN, TAG, '日历本解析失败:%{public}s', JSON.stringify(err));
}
return out;
}
/** 与 `Calendar::isReadonlyKind` 同一口径(这些本不放用户日程) */
private isReadonlyKind(kind: string): boolean {
return kind === 'movie' || kind === 'trip' || kind === 'lunar'
|| kind === 'holiday' || kind === 'traditional' || kind === 'solarterm';
}
/**
* 拉单条日程的完整字段(打开详情/编辑时用)。
*
* ⚠️ `/calendar` 给的是"画格子用的精简条目"(标题/时间/本色/地点),
* 没有 `note`/`rrule`/`reminder`/`ends_at` —— 详情与编辑都需要它们,
* 所以点开某条时再单独拉一次(一次往返、几百毫秒)。
*/
async fetchEvent(id: number, token: string): Promise<FbCalEventFull | null> {
if (id <= 0) {
return null;
}
const res: FbApiResult = await fbApi('/api/v1/events/' + id.toString(), { token: token });
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '日程详情拉取失败(%{public}d):%{public}s', id, res.error);
return null;
}
try {
const o: Record<string, Object> = JSON.parse(res.data) as Record<string, Object>;
return this.toFull(o);
} catch (err) {
hilog.error(DOMAIN, TAG, '日程详情解析失败:%{public}s', JSON.stringify(err));
return null;
}
}
/** 新建日程(`POST /api/v1/events`)。成功时返回新 id(0 = 失败/取不到) */
async create(input: FbCalEventInput, token: string): Promise<FbApiResult> {
const body: Record<string, Object> = this.toBody(input);
const res: FbApiResult = await fbApi('/api/v1/events',
{ method: 'POST', body: body, token: token });
return res;
}
/** 改日程(`PATCH /api/v1/events/<id>`)—— 只传要改的字段 */
async update(id: number, input: FbCalEventInput, token: string): Promise<FbApiResult> {
const body: Record<string, Object> = this.toBody(input);
return await fbApi('/api/v1/events/' + id.toString(),
{ method: 'PATCH', body: body, token: token });
}
/** 删日程(`DELETE /api/v1/events/<id>`) */
async remove(id: number, token: string): Promise<FbApiResult> {
return await fbApi('/api/v1/events/' + id.toString(),
{ method: 'DELETE', token: token });
}
/** 从接口返回里取新 id(取不到返回 0) */
newIdOf(res: FbApiResult): number {
if (!res.ok || res.data === '') {
return 0;
}
try {
const o: Record<string, Object> = JSON.parse(res.data) as Record<string, Object>;
const v: Object | undefined = o['id'];
if (typeof v === 'number') {
return v;
}
if (typeof v === 'string') {
return parseInt(v, 10);
}
} catch (err) {
hilog.warn(DOMAIN, TAG, '解析新日程 id 失败:%{public}s', JSON.stringify(err));
}
return 0;
}
/**
* 输入 → 请求体。
*
* ⚠️ **只放传进来的字段**:服务端每个字段都是"取不到就用原值",
* 少传一个就少覆盖一个 —— 这正是"编辑时只改一项、其余不动"能成立的原因。
* (反过来,若把所有字段都填上默认值发过去,会把用户没动的东西清空。)
*/
private toBody(input: FbCalEventInput): Record<string, Object> {
const b: Record<string, Object> = {};
if (input.calendar_id !== undefined) { b['calendar_id'] = input.calendar_id; }
if (input.title !== undefined) { b['title'] = input.title; }
if (input.location !== undefined) { b['location'] = input.location; }
if (input.starts_at !== undefined) { b['starts_at'] = input.starts_at; }
if (input.ends_at !== undefined) { b['ends_at'] = input.ends_at; }
if (input.all_day !== undefined) { b['all_day'] = input.all_day; }
if (input.note !== undefined) { b['note'] = input.note; }
if (input.rrule !== undefined) { b['rrule'] = input.rrule; }
if (input.status !== undefined) { b['status'] = input.status; }
if (input.reminder_minutes !== undefined) { b['reminder_minutes'] = input.reminder_minutes; }
return b;
}
/** 接口对象 → 强类型(容错:缺字段给默认值) */
private toFull(o: Record<string, Object>): FbCalEventFull {
const e: FbCalEventFull = {
id: this.num(o, 'id'),
calendar_id: this.num(o, 'calendar_id'),
title: this.str(o, 'title'),
location: this.str(o, 'location'),
starts_at: this.str(o, 'starts_at'),
ends_at: this.str(o, 'ends_at'),
all_day: o['all_day'] === true || o['all_day'] === 1,
note: this.str(o, 'note'),
rrule: this.str(o, 'rrule'),
reminder_minutes: this.remStr(o, 'reminder_minutes'),
status: this.str(o, 'status') !== '' ? this.str(o, 'status') : 'confirmed',
transp: o['transp'] === true || o['transp'] === 1,
created_by: this.num(o, 'created_by')
};
return e;
}
private str(o: Record<string, Object>, k: string): string {
const v: Object | undefined = o[k];
if (typeof v === 'string') {
return v;
}
if (typeof v === 'number') {
return v.toString();
}
return '';
}
private num(o: Record<string, Object>, k: string): number {
const v: Object | undefined = o[k];
if (typeof v === 'number') {
return v;
}
if (typeof v === 'string') {
const n: number = parseInt(v, 10);
return isNaN(n) ? 0 : n;
}
return 0;
}
/** 提醒可能是数组、也可能是逗号串 ⇒ 统一成逗号串 */
private remStr(o: Record<string, Object>, k: string): string {
const v: Object | undefined = o[k];
if (v === undefined || v === null) {
return '';
}
if (Array.isArray(v)) {
const parts: string[] = [];
for (const x of v as Object[]) {
parts.push(typeof x === 'number' ? x.toString() : String(x));
}
return parts.join(',');
}
if (typeof v === 'string') {
return v;
}
if (typeof v === 'number') {
return v.toString();
}
return '';
}
}
+161
View File
@@ -0,0 +1,161 @@
/**
* 家书 FamilyBook · 日程设置(鸿蒙端)
*
* 存"日程模块自己的偏好"——它们是**这台设备上的人怎么看日历**,
* 与服务器无关、也不该同步给别人。
*
* 🔴 用户 2026-10-15:「我们家书的日程功能模块,包括它的界面设计和功能,
* 跟 SyncCalendar 做得一样,就差不多。……有几种不同的视图方式,
* 然后包括下面日程的显示啊什么之类的。」
* ⇒ 对齐 SyncCalendar 的设置项:
* · **默认视图**(月 / 周 / 列表)—— 打开日程页时先用哪个
* · **日程显示方式**(时间轴 / 列表)—— 同一天里日程怎么画
* · **新建日程落到哪本日历**(默认日历本)
* · **哪些日历本可见**(隐藏不想看的那几本)
*
* 🔴 为什么用 `preferences` 而不是本地库:都是**单个键值**,
* 不是多行记录(同一范式见 `FbTokenStore` 的说明)。
* ⚠️ 必须**同步读**(`get*()`):页面一渲染就要知道用哪个视图,
* 若只能 await,每个调用点都要 async、把简单逻辑搞碎。
* ⇒ 用"启动时预加载进内存 + 写时同时更新内存"(同 FbTokenStore)。
*/
import { preferences } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { common } from '@kit.AbilityKit';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbCalSettings';
/** preferences 实例名 */
const STORE_NAME: string = 'fb_calendar';
const K_VIEW: string = 'default_view'; // month | week | agenda
const K_STYLE: string = 'display_style'; // timeline | list
const K_BOOK: string = 'default_book_id'; // 新建日程默认落到哪本
const K_HIDDEN: string = 'hidden_books'; // 逗号分隔的日历本 id
/** 视图:月 / 周 / 列表(与 SyncCalendar 的 VIEW_ORDER 同一套) */
export type FbCalView = 'month' | 'week' | 'agenda';
/** 日程画法:时间轴 / 列表(与 SyncCalendar 的 displayStyle 同一套) */
export type FbCalStyle = 'timeline' | 'list';
class FbCalSettingsImpl {
private view: FbCalView = 'month';
private style: FbCalStyle = 'timeline';
private bookId: number = 0;
private hidden: string = '';
private store: preferences.Preferences | null = null;
private ready: boolean = false;
/** 预加载进内存(应用启动时调一次;幂等) */
async init(ctx: common.Context): Promise<void> {
if (this.ready) {
return;
}
try {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
const v: preferences.ValueType = await this.store.get(K_VIEW, 'month');
const s: preferences.ValueType = await this.store.get(K_STYLE, 'timeline');
const b: preferences.ValueType = await this.store.get(K_BOOK, 0);
const h: preferences.ValueType = await this.store.get(K_HIDDEN, '');
this.view = (v === 'week' || v === 'agenda') ? v : 'month';
this.style = (s === 'list') ? 'list' : 'timeline';
this.bookId = typeof b === 'number' ? b : 0;
this.hidden = typeof h === 'string' ? h : '';
this.ready = true;
hilog.info(DOMAIN, TAG, '日程设置预加载完成:%{public}s / %{public}s', this.view, this.style);
} catch (err) {
// ⚠️ 读不到就用默认值,**不要**让启动挂掉
hilog.warn(DOMAIN, TAG, '日程设置预加载失败:%{public}s', JSON.stringify(err));
this.ready = true;
}
}
isReady(): boolean {
return this.ready;
}
// ---- 同步读 ----
getView(): FbCalView {
return this.view;
}
getStyle(): FbCalStyle {
return this.style;
}
getBookId(): number {
return this.bookId;
}
/** 被隐藏的日历本 id 列表 */
hiddenIds(): number[] {
const out: number[] = [];
if (this.hidden === '') {
return out;
}
for (const s of this.hidden.split(',')) {
const n: number = parseInt(s, 10);
if (!isNaN(n) && n > 0) {
out.push(n);
}
}
return out;
}
/** 某本日历是否可见(默认都可见) */
isVisible(id: number): boolean {
return this.hiddenIds().indexOf(id) < 0;
}
// ---- 异步写(同时更新内存) ----
async setView(v: FbCalView): Promise<void> {
this.view = v;
await this.put(K_VIEW, v);
}
async setStyle(s: FbCalStyle): Promise<void> {
this.style = s;
await this.put(K_STYLE, s);
}
async setBookId(id: number): Promise<void> {
this.bookId = id;
await this.put(K_BOOK, id);
}
/** 设置某本日历的可见性 */
async setVisible(id: number, visible: boolean): Promise<void> {
const ids: number[] = this.hiddenIds();
const at: number = ids.indexOf(id);
if (visible && at >= 0) {
ids.splice(at, 1);
} else if (!visible && at < 0) {
ids.push(id);
}
const parts: string[] = [];
for (const n of ids) {
parts.push(n.toString());
}
this.hidden = parts.join(',');
await this.put(K_HIDDEN, this.hidden);
}
private async put(key: string, val: preferences.ValueType): Promise<void> {
try {
if (this.store === null) {
hilog.warn(DOMAIN, TAG, '还没有 store,设置没能落盘(重启会丢)');
return;
}
await this.store.put(key, val);
// ⚠️ `put` 只写内存,**必须 `flush`** 才落盘(同 FbTokenStore 的坑)
await this.store.flush();
} catch (err) {
hilog.error(DOMAIN, TAG, '日程设置保存失败:%{public}s', JSON.stringify(err));
}
}
}
/** 全局单例 */
export const FB_CAL_SETTINGS: FbCalSettingsImpl = new FbCalSettingsImpl();
+460
View File
@@ -0,0 +1,460 @@
/**
* 家书 FamilyBook · 本地库管理器(鸿蒙端)
*
* 职责:打开/创建本地库、首装建表、按版本迁移、提供便捷查询。
*
* 🔴 为什么用**单例**而不是每次 `getRdbStore`:
* RDB 的 store 是可以复用的句柄,反复打开会重复持有连接;
* 而且首装建表/迁移**必须只跑一次**(并发打开会两边都建表、互相报错)。
* ⚠️ 用 Promise 缓存(`openPromise`)而不是"boolean 标志":
* 标志位挡不住并发——两个调用同时进来时,标志都还是 false,
* 于是两个都去打开。缓存 Promise 才能保证"同一时刻只有一次打开"。
*/
import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { FB_DB_VERSION, FB_SCHEMA_SQL, FB_STORE_CONFIG } from './FbSchema';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbDb';
/**
* 本地库管理器。
*
* ⚠️ 用 class + 模块级单例,而不是 static 方法堆在一个类上:
* 需要持有 `store` 句柄与"打开中"的 Promise,这些是**实例状态**。
*/
export class FbDb {
private store: relationalStore.RdbStore | null = null;
/**
* 最后一次打开/操作失败的说明('' = 没失败)。
*
* 🔴 为什么必须留这么一个字段(而不是只打 hilog):
* **本机设备 hilog 读不出内容**(已知限制,试过 `hilog -x -T xxx` 全是空)。
* 而"本地库没建起来"这种问题**只能从失败原因定位** ——
* 留在内存里、由设置页显示出来,是这台设备上唯一可靠的诊断手段。
* ⚠️ 它**不是**给用户看的正式文案(可能是英文异常),
* 是给开发者看的诊断信息,界面要标成"诊断"。
*/
private lastError: string = '';
/** 打开进度打点(走到哪一步了,同样为了诊断) */
private lastStage: string = '';
/** 打开中的 Promise(并发去重,见文件头说明) */
private openPromise: Promise<relationalStore.RdbStore> | null = null;
/**
* 打开(必要时创建)本地库并保证结构就绪。
*
* @param ctx 应用上下文(`getContext()` 得到的能力上下文,
* ⚠️ 不能传 `undefined`——RDB 要靠它定位沙箱目录)
*/
open(ctx: common.Context): Promise<relationalStore.RdbStore> {
if (this.store !== null) {
return Promise.resolve(this.store);
}
if (this.openPromise !== null) {
return this.openPromise; // 已有人在开,跟着等同一个
}
this.openPromise = this.doOpen(ctx);
return this.openPromise;
}
/** 真正的打开逻辑 */
private async doOpen(ctx: common.Context): Promise<relationalStore.RdbStore> {
try {
// 🔴 诊断:把每一步单独打点。
// 第一次跑时"界面显示走到了同步、但沙箱里没有 .db 文件"——
// 说明**某一步静默失败了**。分步打点才能定位是哪一步。
this.lastStage = '[1] 准备 getRdbStore';
hilog.info(DOMAIN, TAG, '[1] 准备 getRdbStore,name=%{public}s', FB_STORE_CONFIG.name);
const store: relationalStore.RdbStore = await relationalStore.getRdbStore(ctx, FB_STORE_CONFIG);
this.lastStage = '[2] getRdbStore 成功,version=' + store.version.toString();
hilog.info(DOMAIN, TAG, '[2] getRdbStore 成功,当前 version=%{public}d', store.version);
// 首装建表:`IF NOT EXISTS` 本身幂等,但**版本迁移**不能这样粗暴跑
await this.createSchemaIfNeeded(store);
this.lastStage = '[3] 建表完成,version=' + store.version.toString();
hilog.info(DOMAIN, TAG, '[3] 建表完成,version=%{public}d', store.version);
await this.migrate(store);
this.lastStage = '[4] 迁移完成,version=' + store.version.toString();
hilog.info(DOMAIN, TAG, '[4] 迁移完成,version=%{public}d', store.version);
this.store = store;
this.lastStage = '[5] 就绪,version=' + store.version.toString();
this.lastError = '';
hilog.info(DOMAIN, TAG, '[5] 本地库就绪,版本=%{public}d', store.version);
return store;
} catch (err) {
// ⚠️ 失败时**必须清掉 openPromise**,否则后续调用永远拿到这个失败的 Promise
// (表现为"重试也没用",很难查)。
this.openPromise = null;
const e: BusinessError = err as BusinessError;
// ⚠️ 必须把 message 完整打出来:BusinessError 的 code 常是 14800xxx 这类
// 通用码,光看码猜不出原因,message 里才有"哪个参数不对"。
this.lastError = 'code=' + (e.code !== undefined ? e.code : -1).toString()
+ ' msg=' + (e.message !== undefined ? e.message : JSON.stringify(err));
hilog.error(DOMAIN, TAG, '打开本地库失败:code=%{public}d message=%{public}s',
e.code !== undefined ? e.code : -1,
e.message !== undefined ? e.message : JSON.stringify(err));
throw err as Error;
}
}
/**
* 首装建表。
*
* ⚠️ 判据是 `store.version === 0`(全新库),**不是**"表数量为 0":
* 后者在有"只建了部分表"的脏状态时会误判。
*/
private async createSchemaIfNeeded(store: relationalStore.RdbStore): Promise<void> {
if (store.version !== 0) {
return; // 老库,交给 migrate()
}
for (const sql of FB_SCHEMA_SQL) {
await store.executeSql(sql);
}
store.version = FB_DB_VERSION;
hilog.info(DOMAIN, TAG, '首装建表完成,%{public}d 条语句', FB_SCHEMA_SQL.length);
}
/**
* 按版本迁移。
*
* 🔴 本地库迁移与服务端**完全不同**:
* 服务端可以"每次请求都全量 ALTER 一遍"(它自己就是服务器、失败可重试);
* 用户的手机不能这么干 —— 逐版本、可预期、失败能停在已知状态。
*
* ⚠️ 目前只有 v1(首版),迁移体是空的占位。
* 将来加表/加列时在这里按 `from` 版本逐级 ALTER,**不要**直接改 FB_SCHEMA_SQL ——
* 那只会影响新装用户,老用户升不上来(这是最常见的迁移翻车方式)。
*/
private async migrate(store: relationalStore.RdbStore): Promise<void> {
const from: number = store.version;
if (from === 0 || from >= FB_DB_VERSION) {
return;
}
hilog.info(DOMAIN, TAG, '本地库迁移:v%{public}d → v%{public}d', from, FB_DB_VERSION);
// ---- v1 → v2:名册支持 ----
// ⚠️ 老库(已经装了观影那版的手机)升级时走到这里。
// v2 的改动是"persons 补 7 列 + 新增 person_roster / person_tags 两张表"。
// ⚠️ SQLite 的 `ALTER TABLE ADD COLUMN` **不支持 IF NOT EXISTS**,
// 所以这里靠"版本号只加一次"来保证不重复执行(from<2 才进来)。
// ⚠️ 新增的列都给上 NOT NULL DEFAULT '' 或允许 NULL ——
// 老数据行会被填上默认值,不会因为加列而失败。
if (from < 2) {
const addCols: string[] = [
`ALTER TABLE persons ADD COLUMN avatar_color TEXT`,
`ALTER TABLE persons ADD COLUMN email TEXT`,
`ALTER TABLE persons ADD COLUMN org TEXT`,
`ALTER TABLE persons ADD COLUMN title TEXT`,
`ALTER TABLE persons ADD COLUMN slogan TEXT`,
`ALTER TABLE persons ADD COLUMN address TEXT`,
`ALTER TABLE persons ADD COLUMN addr_city TEXT`,
];
for (const sql of addCols) {
try {
await store.executeSql(sql);
} catch (err) {
// ⚠️ 已经加过这一列就跳过(比如迁移中途失败后重跑)。
// 这里**不能**直接抛:一列已在会让整次迁移卡死、永远升不上去。
hilog.warn(DOMAIN, TAG, '加列跳过(可能已存在):%{public}s', sql);
}
}
// 两张新表用 IF NOT EXISTS,可重复执行
const newTables: string[] = [
`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)`,
`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)`,
];
for (const sql of newTables) {
await store.executeSql(sql);
}
hilog.info(DOMAIN, TAG, 'v2 迁移完成(名册)');
}
// ---- v2 → v3:通用模块缓存 ----
// ⚠️ 一张"按模块名存 JSON"的通用表(理由见 FbSchema 里 module_cache 的说明)。
// 用 IF NOT EXISTS,可重复执行。
if (from < 3) {
await store.executeSql(
'CREATE TABLE IF NOT EXISTS module_cache ('
+ ' name TEXT PRIMARY KEY, payload TEXT NOT NULL DEFAULT \'\','
+ ' fetched_at INTEGER NOT NULL DEFAULT 0)');
hilog.info(DOMAIN, TAG, 'v3 迁移完成(模块缓存)');
}
// ---- v3 → v4:名册行补 `phones`(JSON 数组,主号在前)----
//
// 🔴 为什么存 JSON 而不是"号码表":
// 号码本来就是**名册同步时一起拿到的**(`/roster` 的 `phones` 字段),
// 单独建表反而要多一次 join、多一套写入逻辑;而这里只需要"整组读出来"。
// ⚠️ 与"标签用 person_tags 表"不同:标签要**跨人聚合**(标签云)、
// 必须能 GROUP BY,所以它值得一张表;电话不需要聚合。
if (from < 4) {
await store.executeSql(
"ALTER TABLE person_roster ADD COLUMN phones TEXT NOT NULL DEFAULT '[]'");
hilog.info(DOMAIN, TAG, 'v4 迁移完成(名册电话)');
}
store.version = FB_DB_VERSION;
}
/** 取已打开的 store;未打开时报错(而不是静默返回 null,避免空指针难查) */
require(): relationalStore.RdbStore {
if (this.store === null) {
throw new Error('本地库还没打开,请先 await FbDb.inst().open(ctx)');
}
return this.store;
}
/** 是否已就绪 */
isReady(): boolean {
return this.store !== null;
}
/** 打开失败的原因('' = 没失败);**诊断用**,见 lastError 的说明 */
errorText(): string {
return this.lastError;
}
/** 打开进度到哪一步;**诊断用** */
stageText(): string {
return this.lastStage;
}
/**
* 执行查询并返回对象数组。
*
* ⚠️ 用 `querySql` 直接写 SQL 而不是 `RdbPredicates`:
* 本项目的查询都是"按 family_id + updated_at 增量拉"这类**带条件的复杂查询**,
* Predicates 表达"IN (子查询)"之类很别扭;而 SQL 是服务端同步过来的原文,
* 直接对照排查更方便。
* ⚠️ 所有外部值一律走 `?` 占位(下方 `args`),**不做字符串拼接** —— 防注入。
*/
async queryRows(sql: string, args: relationalStore.ValueType[] = []): Promise<FbRow[]> {
const store: relationalStore.RdbStore = this.require();
const rs: relationalStore.ResultSet = await store.querySql(sql, args);
const rows: FbRow[] = [];
try {
while (rs.goToNextRow()) {
rows.push(FbRow.fromResultSet(rs));
}
} finally {
rs.close(); // ⚠️ 必须关;不关会泄漏游标,连续查询会报错
}
return rows;
}
/** 执行一条查询,返回第一行(没有则 null) */
async queryOne(sql: string, args: relationalStore.ValueType[] = []): Promise<FbRow | null> {
const rows: FbRow[] = await this.queryRows(sql, args);
return rows.length > 0 ? rows[0] : null;
}
/** 执行写语句(INSERT/UPDATE/DELETE 均可) */
async exec(sql: string, args: relationalStore.ValueType[] = []): Promise<void> {
await this.require().executeSql(sql, args);
}
/** 查询单个数值(COUNT/MAX 之类) */
/**
* 查询单个数值(COUNT / MAX 之类)。
*
* ⚠️ 必须走 `FbRow.num()` 而不是 `typeof v === 'number' ? v : 0`:
* SQLite 的 `COUNT(*)` 经 `getValue()` 取回来可能是 **bigint**
* (或字符串),只认 `number` 会把**真实结果错判成 0** ——
* 实测踩过:库里明明有 200 条,自检却报 `ev0`,
* 一度误判成"数据没写进去",白查了很久。
* ⇒ 交给 `num()` 统一做类型收敛。
*/
async scalar(sql: string, args: relationalStore.ValueType[] = []): Promise<number> {
const row: FbRow | null = await this.queryOne(sql, args);
if (row === null) {
return 0;
}
const keys: string[] = row.keys();
if (keys.length === 0) {
return 0;
}
return row.num(keys[0], 0);
}
/**
* 在事务里跑一段操作(**用 `createTransaction` 的对象式 API**)。
*
* 🔴🔴 这里踩过一个很深的坑,务必记住(完整排查过程见文末):
* · 老的 `store.beginTransaction()` + `store.commit()`(无参三件套)
* 在中间有 `await` 时会**丢事务上下文** ⇒
* "数据看似写进去了(同连接能查到)、`COUNT(*)` 却是 0、
* 且 Promise 永不返回"。
* · 换成 `createTransaction()` 拿到**事务对象**后,
* 在它上面用 `batchInsertSync`(同步版)仍然**卡住**
* (实测停在"4/6 组装数据"→写入之间,几分钟不返回)。
* ⇒ 最终可行组合:**`createTransaction()` 对象 + 在事务对象上 `await` 异步版**
* (`batchInsert` / `execute`)。事务上下文由**对象**持有,
* 所以这里的 `await` 不会像老三件套那样丢上下文。
*
* ⚠️ 回调必须是 `async` 且**逐个 await 完成后再返回** ——
* 回调返回后才会 `commit()`,中途还有未完成的写入就会提交不完整。
*/
async transaction(work: (tx: relationalStore.Transaction) => Promise<void>): Promise<void> {
const store: relationalStore.RdbStore = this.require();
const tx: relationalStore.Transaction = await store.createTransaction();
try {
await work(tx);
await tx.commit();
} catch (err) {
// ⚠️ 回滚也 await;失败时不往上抛(避免掩盖原始错误)
try {
await tx.rollback();
} catch (rollErr) {
hilog.error(DOMAIN, TAG, '回滚也失败了:%{public}s', JSON.stringify(rollErr));
}
hilog.error(DOMAIN, TAG, '事务回滚:%{public}s', JSON.stringify(err));
throw err as Error;
}
}
/** 关闭本地库(一般只在退出应用时调用) */
async close(): Promise<void> {
if (this.store !== null) {
await this.store.close();
this.store = null;
this.openPromise = null;
}
}
// ---- 单例 ----
private static instance: FbDb | null = null;
static inst(): FbDb {
if (FbDb.instance === null) {
FbDb.instance = new FbDb();
}
return FbDb.instance;
}
}
/**
* 一行查询结果(列名 → 值)。
*
* ⚠️ 为什么不用 `Record<string, relationalStore.ValueType>` 直接返回:
* ResultSet 的列值要**逐个按列名取**(`getString` / `getLong` / `isColumnNull`),
* 直接塞进 Record 会丢掉"这一列是否为 NULL"的信息
* (SQLite 里 NULL 与空串是两回事,把 NULL 变成 '' 会让"有没有填备注"判错)。
* 这里用一个小包装类把 ResultSet 的语义完整带出来。
*/
export class FbRow {
private data: Map<string, relationalStore.ValueType> = new Map<string, relationalStore.ValueType>();
private nulls: Set<string> = new Set<string>();
/** 从 ResultSet 当前行读全部列 */
static fromResultSet(rs: relationalStore.ResultSet): FbRow {
const row: FbRow = new FbRow();
const names: string[] = rs.getColumnNames();
for (const name of names) {
const idx: number = rs.getColumnIndex(name);
if (rs.isColumnNull(idx)) {
row.nulls.add(name); // 记住"这列是 NULL",不是空值
row.data.set(name, '');
} else {
// ⚠️ 用 `getValue(idx)` 而不是 `getColumnType(idx)` 再分支:
// · `getColumnType` 返回的是 **Promise**(异步!),在同步循环里用会拿到
// Promise 对象、所有比较都成立不了(第一版就这么错的);
// · 就算换成 `getColumnTypeSync`,也还是"先问类型再取值"两步。
// `getValue` 直接按列的真实类型返回 `ValueType`,一步到位、不丢类型。
row.data.set(name, rs.getValue(idx));
}
}
return row;
}
keys(): string[] {
const out: string[] = [];
this.data.forEach((_v: relationalStore.ValueType, k: string) => {
out.push(k);
});
return out;
}
/** 该列是否为 NULL */
isNull(name: string): boolean {
return this.nulls.has(name);
}
get(name: string): relationalStore.ValueType {
const v: relationalStore.ValueType | undefined = this.data.get(name);
return v !== undefined ? v : '';
}
/** 取字符串;NULL 会返回 null(与"空串"区分开) */
str(name: string): string | null {
if (this.isNull(name)) {
return null;
}
const v: relationalStore.ValueType = this.get(name);
if (typeof v === 'string') {
return v;
}
if (typeof v === 'number' || typeof v === 'bigint' || typeof v === 'boolean') {
return String(v); // ⚠️ ValueType 含 bigint/boolean,都要能转
}
return null; // Uint8Array / Asset 之类不走字符串通道
}
/** 取字符串,NULL 折成空串(表单显示用) */
strOr(name: string, fallback: string): string {
const v: string | null = this.str(name);
return v !== null ? v : fallback;
}
/** 取整数;NULL 折成 fallback */
num(name: string, fallback: number): number {
if (this.isNull(name)) {
return fallback;
}
const v: relationalStore.ValueType = this.get(name);
if (typeof v === 'number') {
return v;
}
if (typeof v === 'bigint') {
return Number(v);
}
if (typeof v === 'string') {
const n: number = Number(v);
return Number.isNaN(n) ? fallback : n;
}
if (typeof v === 'boolean') {
return v ? 1 : 0;
}
return fallback;
}
/** 取布尔(SQLite 用 0/1 存) */
bool(name: string): boolean {
return this.num(name, 0) !== 0;
}
}
+163
View File
@@ -0,0 +1,163 @@
/**
* 家书 FamilyBook · 设备身份(鸿蒙端)
*
* 只干一件事:告诉服务器「我是哪台设备」。
*
* 🔴 v57(用户 2026-10-17):
* 「我们在请求服务端的时候,就需要把我们的客户端的这个设备信息,
* 就是我的设备信息,提交到我们的服务器端。」
* 「只有 uuid 用户无法确定是哪台设备」——所以还要一个**人看得懂**的名字。
*
* ⚠️ **为什么 device_id 是 App 自己生成的 UUID,而不是硬件号**:
* HarmonyOS 三方应用**拿不到** UDID / 序列号(`ohos.permission.sec.ACCESS_UDID`
* 是系统级权限,上架审核也会拒)。能拿到的只有机型、系统版本这类**非唯一**信息。
* ⇒ 首次启动生成一个 UUID 存在本地,之后一直用它。**卸载重装 = 一台新设备**
* (用户 2026-10-17 已确认:"卸载重装就是新设备,没问题")。
*
* ⚠️ 设备名默认取机型(`marketName`),用户可以在设置页改 ——
* 家里两台一模一样的手机时,"我的 Mate 40" 变成 "爸爸的手机" 才认得出。
*
* ⚠️ 与 `FbTokenStore` 同构(启动预加载 + 同步读):
* 网络层要**同步**拿到 device_id 拼请求体,异步读会把简单逻辑打碎。
*/
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { deviceInfo } from '@kit.BasicServicesKit';
import { util } from '@kit.ArkTS';
import { hilog } from '@kit.PerformanceAnalysisKit';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbDevice';
const STORE_NAME: string = 'fb_device';
const KEY_ID: string = 'device_id';
const KEY_NAME: string = 'device_name';
/**
* 客户端版本号(与 `AppScope/app.json5` 里的 versionName 对齐)。
*
* ⚠️ 写常量而不是从 bundleManager 读:这里只需要一个"能对账"的字符串,
* 读 bundle 要走异步 API + 多一个系统依赖,收益不值。
* 发版时记得改这一行(设备管理页会显示它,用户能看出"这台还是旧版本")。
*/
export const FB_APP_VERSION: string = '0.7.0'; // 与 AppScope/app.json5 的 versionName 保持一致
/** 平台描述:`harmonyos 5.0.1`(拿不到系统名就退回 API 版本) */
function platformText(): string {
const name: string = deviceInfo.osFullName;
if (name !== undefined && name !== '') {
return ('harmonyos ' + name).substring(0, 30);
}
return ('harmonyos api' + deviceInfo.sdkApiVersion.toString()).substring(0, 30);
}
/** 机型默认名:`HUAWEI DEL-AL20` 这种;拿不到就给个通用称呼 */
function defaultDeviceName(): string {
const market: string = deviceInfo.marketName;
const brand: string = deviceInfo.brand;
if (market !== undefined && market !== '') {
return market.substring(0, 60);
}
if (brand !== undefined && brand !== '') {
return (brand + ' 鸿蒙设备').substring(0, 60);
}
return '我的手机';
}
class FbDeviceStoreImpl {
private deviceId: string = '';
private deviceName: string = '';
private store: preferences.Preferences | null = null;
private ready: boolean = false;
/**
* 预加载设备身份;**首次启动时生成** UUID 并落盘。
* 应在应用启动时调用一次(见 EntryAbility)。
*/
async init(ctx: common.Context): Promise<void> {
if (this.ready) {
return;
}
try {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
const id: preferences.ValueType = await this.store.get(KEY_ID, '');
let sid: string = typeof id === 'string' ? id : '';
if (sid === '') {
// 首次启动:生成一个并**立刻落盘**(不然每次启动都是一台"新设备",
// 十几次就把服务端的 10 台上限撞满,账号被自己锁死)
sid = util.generateRandomUUID(false);
await this.store.put(KEY_ID, sid);
await this.store.flush();
hilog.info(DOMAIN, TAG, '首次启动,已生成设备 ID');
}
this.deviceId = sid;
const nm: preferences.ValueType = await this.store.get(KEY_NAME, '');
const snm: string = typeof nm === 'string' ? nm : '';
this.deviceName = snm !== '' ? snm : defaultDeviceName();
this.ready = true;
hilog.info(DOMAIN, TAG, '设备身份就绪');
} catch (err) {
// ⚠️ 读不到不能挡住启动:给一个**进程内临时**身份,本次能用,
// 只是下次启动会变成"另一台设备"(服务端表现为多一台,用户可踢掉)。
hilog.warn(DOMAIN, TAG, '设备身份初始化失败:%{public}s', JSON.stringify(err));
if (this.deviceId === '') {
this.deviceId = util.generateRandomUUID(false);
}
if (this.deviceName === '') {
this.deviceName = defaultDeviceName();
}
this.ready = true;
}
}
/** 设备号(同步;未 init 时也保证非空 —— 临时身份) */
getDeviceId(): string {
if (this.deviceId === '') {
this.deviceId = util.generateRandomUUID(false);
}
return this.deviceId;
}
/** 设备名(同步) */
getDeviceName(): string {
if (this.deviceName === '') {
this.deviceName = defaultDeviceName();
}
return this.deviceName;
}
/** 平台描述(同步,如 `harmonyos HarmonyOS 5.0.1`) */
platform(): string {
return platformText();
}
/** 预加载是否完成(同步) */
isReady(): boolean {
return this.ready;
}
/** 改设备名("爸爸的手机"这种),只影响这台机器在服务端列表里的显示名 */
async setDeviceName(ctx: common.Context, name: string): Promise<boolean> {
const v: string = name.trim().substring(0, 60);
if (v === '') {
return false;
}
try {
if (this.store === null) {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
}
await this.store.put(KEY_NAME, v);
await this.store.flush();
this.deviceName = v;
return true;
} catch (err) {
hilog.error(DOMAIN, TAG, '设备名保存失败:%{public}s', JSON.stringify(err));
return false;
}
}
}
/** 全局单例 */
export const FB_DEVICE_STORE: FbDeviceStoreImpl = new FbDeviceStoreImpl();
+385
View File
@@ -0,0 +1,385 @@
/**
* 家书 FamilyBook · 网络层(鸿蒙端)
*
* 只做一件事:把 HTTP 请求发出去、把服务端的统一信封拆开。
* **不碰数据库、不碰 UI** —— 保持"进数据的通道"单一职责。
*
* 服务端信封(`app/Api.php` 的 `Api::ok` / `Api::fail`):
* 成功:`{ "ok": true, "data": <载荷>, ...额外字段 }`
* 失败:`{ "ok": false, "error": "人话错误说明", "code": "机器码" }`
* ⚠️ HTTP 状态码同时也有意义(401/403/404/422…),别只看 `ok`。
*
* 🔴 为什么把"信封拆解"放在这一层,而不是各页面自己判断 `resp.ok`:
* 服务端换了信封格式(历史上改过一次),分散判断就要改 N 处;
* 收在这一层,只改一个地方。
*/
import { http } from '@kit.NetworkKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { FB_SERVER_STORE } from './FbServerStore';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbHttp';
/**
* 预填用的默认服务器地址(**仅用于"首次登录"输入框的初始值**)。
*
* 🔴 v55(用户 2026-10-17「应用第一次打开,让用户输入服务器地址」)——
* 地址从"编译期常量"变成了"运行期配置"(见 `FbServerStore`)。
* 这个常量**不再是请求目标**:请求一律走 `fbApiBase()` 读用户配置。
* ⚠️ 留着它只是为了让老用户升级后不用手打域名 —— 输入框预填、点一下就连上。
* 真正生效的永远是用户确认过的那一份。
*/
export const FB_DEFAULT_SERVER: string = 'https://family.yangyq.net';
/**
* 当前服务器基址(用户配置的那一台;未配置返回空串)。
*
* ⚠️ **每次调用都读一次内存缓存**,不要在模块顶层 `const BASE = fbApiBase()` ——
* 那样用户"更换服务器"之后,本进程里发出去的请求还在打旧地址
* (模块顶层只求值一次,且本模块在启动时就加载了)。
* ⇒ 所有拼 URL 的地方都调这个函数。
*/
export function fbApiBase(): string {
return FB_SERVER_STORE.get();
}
/**
* 拼一个可直接给 `Image()` 用的图片地址。
*
* 🔴 为什么图片要走 `media.php` 而不是直连存储:
* 服务端所有附件(海报/头像/照片)都由 `media.php?k=<相对键>` 代理出去 ——
* 存储可能是本地目录、也可能是 OSS,代理让地址形态恒定(服务端注释里写明)。
* 客户端只认这一个形式即可。
*
* 🔴 为什么令牌要放在 **URL query** 里(`&token=`):
* `Image()` 加载网络地址时**无法附加自定义请求头**,
* 所以令牌只能走 URL。(服务端 v45 起 `media.php` 支持 `?token=` 鉴权,
* 与 `api.php` 的"也接受 `?token=`"是同一约定。)
*
* ⚠️ 必须对 key 做 `encodeURIComponent`:key 里有 `/`(如
* `posters/xxx.jpg`)和可能的特殊字符,不编码会把 query 弄坏。
* ⚠️ 但 serve 端会做键净化(拒绝 `..`),所以这里编码是"正确性"需要,
* 不是安全防线(安全由服务端负责)。
*
* @param key 附件的相对键(如 `posters/963dd25f.jpg`);空串返回空串
* @param token API 令牌;空串时也返回地址(服务端会给 401,界面显示占位)
*/
export function fbImageUrl(key: string, token: string): string {
if (key === '') {
return '';
}
const base: string = fbApiBase();
if (base === '') {
return '';
}
let url: string = base + '/media.php?k=' + encodeURIComponent(key);
if (token !== '') {
url += '&token=' + encodeURIComponent(token);
}
return url;
}
/** 一次接口调用的结果 */
export interface FbApiResult {
/** 是否成功(HTTP 2xx 且信封 ok=true) */
ok: boolean;
/** 成功时的载荷(未成功时为 null) */
data: string; // 原始 JSON 片段,由调用方按类型解析
/** 失败时的人话说明(面向用户展示) */
error: string;
/** 失败时的机器码(用于分支判断,如 'unauthorized') */
code: string;
/** HTTP 状态码 */
status: number;
}
/** 请求参数(所有字段都可选,避免调用方写一堆占位) */
export interface FbApiOptions {
/** 请求方法,默认 GET */
method?: string;
/** 查询参数(会被 URL 编码拼到 path 后) */
query?: Record<string, string>;
/**
* 请求体(会被 JSON 序列化;GET 时忽略)。
*
* 🔴 值类型放宽到 `Object`(v52,用户 2026-10-15:名册要"全字段可编辑"):
* 之前只允许 `string|number|boolean|null`,于是**数组/嵌套对象传不进去** ——
* 而工作经历(一组行)、补充联系方式(按 kind 分组)、标签(字符串数组)
* 都是**结构化的**。旧限制下只能把这些字段漏掉或自己拼字符串,
* 那正是"编辑时只能改几项"的来源。
* ⇒ 允许任意可 JSON 化的值,由 `JSON.stringify` 统一处理。
*/
body?: Record<string, Object>;
/** API 令牌(不带则请求匿名,服务端会 401) */
token?: string;
/**
* `true` = 这次请求的 401 **不要**触发全局"踢回登录页"。
*
* 🔴 v57(用户 2026-10-17)。为什么需要这个开关:
* 服务端把所有"令牌无效/过期/被踢"都回 401,而**密码错**也是 401。
* 如果无脑拦截,用户在登录页输错一次密码就会被"全局锁定"逻辑再踢一次,
* 表现为"输错密码后整个 App 闪一下"。
* ⇒ 只有**带着令牌**的业务请求才该触发锁定;登录、探测令牌这类
* 自己会处理结果的请求,显式传 `authProbe: true`。
*/
authProbe?: boolean;
}
/**
* 「令牌失效了」的全局回调(v57)。
*
* 🔴 为什么放在网络层而不是每个页面自己判:
* 用户 2026-10-17:「运行期间任何一个请求返回 401,立刻锁定跳登录页 ——
* 这是真正的兜底,因为同步/写入本来就在不断发请求。」
* 页面有十几个,任何一个漏判都会出现"页面空着但没回登录页"。
* 收在网络层 = **只有一处**,永远漏不掉。
*
* ⚠️ 回调由 `EntryAbility` 注册(它拿得到 UIAbilityContext,能做真正的跳转),
* 网络层自己不碰路由/状态。
*/
export type FbUnauthorizedCb = () => void;
let unauthorizedCb: FbUnauthorizedCb | null = null;
/** 注册「令牌失效」回调(重复注册会覆盖,只保留最后一个) */
export function fbOnUnauthorized(cb: FbUnauthorizedCb): void {
unauthorizedCb = cb;
}
/**
* 发一次 API 请求。
*
* ⚠️ 超时给 15 秒:家庭应用走公网 + 可能跨运营商,5 秒太容易误判"网络不可用";
* 但也别给太长(60 秒),否则用户点了"刷新"要干等。
*/
export async function fbApi(path: string, opts: FbApiOptions = {}): Promise<FbApiResult> {
const method: string = opts.method !== undefined ? opts.method : 'GET';
// 🔴 没配服务器地址时**立刻失败**,不要拼一个 `/api/v1/xxx` 的相对地址发出去
// (那会落到一个说不清的网络上,报出来的错误跟"地址没填"毫无关系,
// 用户根本猜不到是哪里错了)
const base: string = fbApiBase();
if (base === '') {
return {
ok: false, data: '', error: '还没有设置服务器地址', code: 'no_server', status: 0
};
}
const url: string = base + path + buildQuery(opts.query);
const req: http.HttpRequest = http.createHttp();
try {
const header: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json'
};
if (opts.token !== undefined && opts.token !== '') {
header['Authorization'] = 'Bearer ' + opts.token;
}
const extra: http.HttpRequestOptions = {
method: toMethod(method),
header: header,
connectTimeout: 15000,
readTimeout: 15000,
expectDataType: http.HttpDataType.STRING
};
if (opts.body !== undefined && method !== 'GET') {
extra.extraData = JSON.stringify(opts.body);
}
const resp: http.HttpResponse = await req.request(url, extra);
const body: string = typeof resp.result === 'string' ? resp.result : '';
const out: FbApiResult = parseEnvelope(body, resp.responseCode);
/* v57:401 = 令牌无效/过期/被踢 → 立刻通知全局锁定(见 FbUnauthorizedCb 的说明)。
⚠️ 判的是 **HTTP 状态码**而不是 `code` 字段:服务端对三种情况都回 401,
端上不需要(也不该)区分是"过期"还是"被踢"——处理动作完全一样。 */
if (out.status === 401 && opts.authProbe !== true && unauthorizedCb !== null) {
unauthorizedCb();
}
return out;
} catch (err) {
// ⚠️ 网络异常(断网/DNS/超时)走这里,**不是** HTTP 错误码。
// 必须与"服务端返回 4xx"区分开:前者可重试,后者重试无意义。
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '请求失败 %{public}s:%{public}s', path, JSON.stringify(e));
return {
ok: false,
data: '',
error: '网络连接不上,请检查手机网络',
code: 'network',
status: 0
};
} finally {
req.destroy(); // ⚠️ 必须销毁;不销毁会占着连接(HttpRequest 不是自动回收的)
}
}
/** 拆服务端信封 */
function parseEnvelope(body: string, status: number): FbApiResult {
if (body === '') {
return { ok: false, data: '', error: '服务端没有返回内容', code: 'empty', status: status };
}
let obj: Record<string, Object> | null = null;
try {
obj = JSON.parse(body) as Record<string, Object>;
} catch (err) {
// 非 JSON(例如 Apache 的 502 页面)—— 别把 HTML 当错误信息弹给用户
hilog.warn(DOMAIN, TAG, '响应不是 JSON(HTTP %{public}d)', status);
return {
ok: false, data: '', error: '服务端返回异常(HTTP ' + status + ')',
code: 'bad_json', status: status
};
}
const okVal: Object | undefined = obj['ok'];
const isOk: boolean = okVal === true;
if (isOk) {
// data 可能是对象/数组/标量,统一序列化回字符串交给调用方解析,
// 避免在这一层就假定形状(不同接口的 data 结构差别很大)
const d: Object | undefined = obj['data'];
return {
ok: true,
data: d !== undefined ? JSON.stringify(d) : '',
error: '', code: '', status: status
};
}
const errObj: Object | undefined = obj['error'];
const codeObj: Object | undefined = obj['code'];
return {
ok: false,
data: '',
error: typeof errObj === 'string' ? errObj : '请求失败(HTTP ' + status + ')',
code: typeof codeObj === 'string' ? codeObj : '',
status: status
};
}
/** 拼查询串(值做 URL 编码,避免关键词里的空格/中文把 URL 弄坏) */
function buildQuery(query?: Record<string, string>): string {
if (query === undefined) {
return '';
}
const parts: string[] = [];
const keys: string[] = Object.keys(query);
for (const k of keys) {
const v: string = query[k];
parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
}
return parts.length > 0 ? '?' + parts.join('&') : '';
}
/**
* 字符串方法名 → `http.RequestMethod` 枚举。
*
* ⚠️ 必须显式映射:`RequestMethod` 是**枚举**,不能直接把字符串赋过去
* (ArkTS 是强类型,`method: 'GET'` 编译不过)。
*/
function toMethod(m: string): http.RequestMethod {
switch (m.toUpperCase()) {
case 'POST': return http.RequestMethod.POST;
case 'PUT': return http.RequestMethod.PUT;
case 'DELETE': return http.RequestMethod.DELETE;
case 'PATCH': return http.RequestMethod.PATCH;
case 'HEAD': return http.RequestMethod.HEAD;
case 'OPTIONS': return http.RequestMethod.OPTIONS;
case 'TRACE': return http.RequestMethod.TRACE;
case 'CONNECT': return http.RequestMethod.CONNECT;
default: return http.RequestMethod.GET;
}
}
/* ═══════════════════════════════════════════════════════════════════
服务器地址探测(登录第一步:确认"这个地址上真的是家书")
═══════════════════════════════════════════════════════════════════ */
/** 探测结果 */
export interface FbProbeResult {
ok: boolean;
/** 服务端自报的应用名(通常是「家书」) */
app: string;
/** 失败原因(ok=false 时有值,可直接展示) */
error: string;
}
/** 服务端 `GET /api/v1/ping` 的 data 形状 */
interface FbPingData {
service?: string;
api?: string;
app?: string;
}
interface FbPingEnvelope {
ok?: boolean;
error?: string;
code?: string;
data?: FbPingData;
}
/**
* 探测一个服务器地址是不是家书服务端。
*
* 🔴 为什么"只回 HTTP 200"不够(必须要这个函数):
* 用户填错地址时,最常见的是"填到了另一个网站"或"填成了家书的某个网页路径"——
* 两者都会返回 200。此时若直接放行,用户会在登录页反复试密码、永远失败,
* 而且**看不出原因**。
* ⇒ 必须验**内容**:`data.service === 'familybook'`。
*
* ⚠️ 超时给 8 秒(比业务请求的 15 秒短):探测是"用户点了按钮在等"的动作,
* 等太久他会以为卡死并反复点。
* ⚠️ 这里**不读 store**:探测的就是"用户刚输入的那个地址",
* 验通了才让它写进 store(见登录页的 `connect()`)。
*
* @param baseUrl 已归一化的基址(见 `fbNormalizeServer`)
*/
export async function fbProbeServer(baseUrl: string): Promise<FbProbeResult> {
const req: http.HttpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await req.request(baseUrl + '/api/v1/ping', {
method: http.RequestMethod.GET,
header: { 'Accept': 'application/json' },
connectTimeout: 8000,
readTimeout: 8000,
expectDataType: http.HttpDataType.STRING
});
const body: string = typeof resp.result === 'string' ? resp.result : '';
return parseProbe(body, resp.responseCode);
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '探测服务器失败 %{public}s:%{public}s', baseUrl, JSON.stringify(e));
return { ok: false, app: '', error: '连不上这个地址,请检查域名是否正确、手机是否联网' };
} finally {
req.destroy();
}
}
/** 拆 ping 响应,把"填错了"的几种情况翻成人话 */
function parseProbe(body: string, status: number): FbProbeResult {
if (body === '') {
return { ok: false, app: '', error: '这个地址没有响应(HTTP ' + status.toString() + ')' };
}
let env: FbPingEnvelope;
try {
env = JSON.parse(body) as FbPingEnvelope;
} catch (err) {
// 返回了 HTML —— 十有八九是"填成了别的网站"或"填的是网页路径"
return {
ok: false, app: '',
error: '这个地址返回的不是家书接口(可能填成了别的网站,或少了子目录)'
};
}
const d: FbPingData | undefined = env.data;
const svc: string = d !== undefined && d.service !== undefined ? d.service : '';
if (env.ok === true && svc === 'familybook') {
return {
ok: true,
app: d !== undefined && d.app !== undefined ? d.app : '家书',
error: ''
};
}
if (env.ok !== true && env.error !== undefined && env.error !== '') {
// 服务端明确说了原因(如"还没安装")—— 原样透出
return { ok: false, app: '', error: env.error };
}
return { ok: false, app: '', error: '这个地址不是家书服务器' };
}
+192
View File
@@ -0,0 +1,192 @@
/**
* 家书 FamilyBook · 登录客户端(鸿蒙端)
*
* 只做一件事:把「账号 + 密码」换成「API 令牌」。
*
* 🔴 为什么单独一个文件、不塞进 `FbHttp`:
* `FbHttp` 是"带令牌发请求"的通道;而登录是"**还没令牌时**怎么拿到令牌"
* —— 它在鉴权链路**之前**,语义上是另一件事。
* 放一起会让 `fbApi` 那层出现"要不要带令牌"的分支(现在它假设一定带)。
*
* ⚠️ 密码**不落盘、不进日志**。这里只在函数参数里存在,
* 调用方在拿到结果后立刻清掉自己的副本(见 `FbLoginPage.submit`)。
*
* ⚠️ 地址**从 store 读**(`fbApiBase()`),不接收调用方传的 base:
* 登录页在"连接服务器"那一步已经把地址写进去了,
* 这里再收一个参数会多出一条"传的和存的不一样"的路(迟早出问题)。
* 探测要验"还没存的地址",那是 `fbProbeServer()` 的事。
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
import { http } from '@kit.NetworkKit';
import { fbApiBase } from './FbHttp';
import { FB_DEVICE_STORE, FB_APP_VERSION } from './FbDeviceStore';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbLogin';
/** 登录结果 */
export interface FbLoginResult {
ok: boolean;
/** 成功时的 API 令牌(fb_ 开头) */
token: string;
/** 令牌过期时间('' = 永久) */
expiresAt: string;
personId: number;
personName: string;
/** 服务端角色(admin / member / guest)—— ⚠️ 不是 `/me` 里那个中文的 role */
role: string;
/** 家庭名('' = 服务端没给,不影响登录) */
familyName: string;
/** 服务端时间(ISO8601)—— 用来记"刚校验过",'' = 老服务端没给 */
serverTime: string;
/** 失败原因(ok=false 时有值,可直接展示给用户) */
error: string;
}
/** 服务端 `POST /api/v1/login` 的响应形状 */
interface FbApiLoginData {
token?: string;
expires_at?: string | null;
/** 令牌有效期(小时)。v57 起服务端一律 24(滑动续期) */
ttl_hours?: number;
/** 服务端当前时间(ISO8601)。端上用它记"上次校验成功的时间" */
server_time?: string;
person?: FbApiLoginPerson;
family?: FbApiLoginFamily;
}
interface FbApiLoginPerson {
id?: number;
name?: string;
role?: string;
}
interface FbApiLoginFamily {
id?: number;
name?: string;
}
interface FbApiEnvelope {
ok?: boolean;
error?: string;
code?: string;
data?: FbApiLoginData;
}
class FbLoginClientImpl {
/**
* 登录:账号(手机号或用户名)+ 密码 → 令牌。
*
* ⚠️ 超时给 15 秒(与 `fbApi` 一致):登录要过密码哈希(服务端故意加了延迟),
* 比普通 GET 慢,但也不该让用户干等。
*/
async login(account: string, password: string): Promise<FbLoginResult> {
const base: string = fbApiBase();
if (base === '') {
return fail('还没有设置服务器地址');
}
const url: string = base + '/api/v1/login';
/* v57(用户 2026-10-17):登录必须带上**设备身份**。
「我们在请求服务端的时候,就需要把我们的客户端的这个设备信息,
就是我的设备信息,提交到我们的服务器端。」
⇒ 服务端才能做到"一台设备一个会话、重登换新令牌、网页端能踢掉这一台"。
⚠️ `device_id` 是本地生成的 UUID(不是硬件号,见 FbDeviceStore 的说明);
`device_name` 是机型,用户可以在设置里改成人话("爸爸的手机")。 */
const body: string = JSON.stringify({
// 🔴 参数名用 `account`(服务端 v55 起认「手机号或用户名」);
// 老服务端只认 `phone` —— 所以**两个都带**,谁认识哪个用哪个。
account: account,
phone: account,
password: password,
device_id: FB_DEVICE_STORE.getDeviceId(),
device_name: FB_DEVICE_STORE.getDeviceName(),
platform: FB_DEVICE_STORE.platform(),
app_version: FB_APP_VERSION,
// ⚠️ `label` 只给"还没升级的旧服务端"看(v57 起服务端用 device_name);
// `days` 同理,v57 起服务端**不再读**它(一律 24h 滑动)。
label: '鸿蒙 App',
});
const req = http.createHttp();
try {
const resp: http.HttpResponse = await req.request(url, {
method: http.RequestMethod.POST,
header: { 'Content-Type': 'application/json' },
extraData: body,
connectTimeout: 15000,
readTimeout: 15000,
// ⚠️ 与 `fbApi` 保持一致:默认 `result` 可能是 ArrayBuffer,
// 显式要 STRING 才能直接当 JSON 解析。
expectDataType: http.HttpDataType.STRING,
});
const text: string = typeof resp.result === 'string' ? resp.result : '';
return this.parse(resp.responseCode, text);
} catch (err) {
// ⚠️ 网络异常**不要**把原始错误直接抛给用户看(一串英文 exception),
// 给一句人话;详细原因进日志。
hilog.error(DOMAIN, TAG, '登录请求失败:%{public}s', JSON.stringify(err));
return fail('连不上服务器,请检查网络后重试');
} finally {
// ⚠️ 必须销毁:`http.createHttp()` 每次都会占用一个连接对象,
// 不 destroy 会持续泄漏(登录页反复重试时尤其明显)。
req.destroy();
}
}
/** 解析响应(服务端信封:{ok, data} / {ok:false, error}) */
private parse(status: number, text: string): FbLoginResult {
if (text === '') {
return fail('服务器没有返回内容(HTTP ' + status.toString() + ')');
}
let env: FbApiEnvelope;
try {
env = JSON.parse(text) as FbApiEnvelope;
} catch (err) {
hilog.error(DOMAIN, TAG, '登录响应不是 JSON:%{public}s', text.substring(0, 200));
return fail('服务器返回了无法识别的内容');
}
if (env.ok !== true) {
// ⚠️ 服务端给的 `error` 是**可以直接给用户看**的人话
// ("手机号或密码不对。" / "尝试太频繁了…"),原样透出即可。
const msg: string = env.error !== undefined && env.error !== '' ? env.error : '登录失败';
return fail(msg);
}
const d: FbApiLoginData | undefined = env.data;
const token: string = d !== undefined && d.token !== undefined ? d.token : '';
if (token === '') {
return fail('登录成功但没拿到令牌(服务端返回异常)');
}
const p: FbApiLoginPerson | undefined = d !== undefined ? d.person : undefined;
const f: FbApiLoginFamily | undefined = d !== undefined ? d.family : undefined;
const res: FbLoginResult = {
ok: true,
token: token,
expiresAt: d !== undefined && d.expires_at !== null && d.expires_at !== undefined
? d.expires_at : '',
personId: p !== undefined && p.id !== undefined ? p.id : 0,
personName: p !== undefined && p.name !== undefined ? p.name : '',
role: p !== undefined && p.role !== undefined ? p.role : '',
familyName: f !== undefined && f.name !== undefined ? f.name : '',
serverTime: d !== undefined && d.server_time !== undefined ? d.server_time : '',
error: '',
};
return res;
}
}
/** 造一个失败结果 */
function fail(msg: string): FbLoginResult {
const r: FbLoginResult = {
ok: false, token: '', expiresAt: '', personId: 0, personName: '',
role: '', familyName: '', serverTime: '', error: msg
};
return r;
}
/** 全局单例 */
export const FbLoginClient: FbLoginClientImpl = new FbLoginClientImpl();
+92
View File
@@ -0,0 +1,92 @@
/**
* 家书 FamilyBook · 页面数据接入辅助(鸿蒙端)
*
* 各模块页面的数据装配**都是同一套动作**:
* ① 页面出现 → ② 调仓库 `load()` → ③ 先拿缓存渲染、再拿网络刷新 → ④ 解析成端上类型。
* 这份文件把 ②④ 收成一处,页面只写"我关心哪个模块、怎么解析"。
*
* 🔴 为什么要抽(而不是每页各写一遍):
* 10 个模块 × 同一段 20 行样板 = 200 行重复代码,
* 而重复代码的代价不是"多打几个字",是**改一处漏九处**
* (本项目在别的地方已经吃过这个亏)。
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbModuleLoad, FbModuleName, FbModuleRepo } from './FbModuleRepo';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbModuleLoad';
/**
* 一次性加载(**直接给路径**,不必登记在上面的静态表里)。
*
* ⚠️ 给"两级接口"用(如账本明细 `/ledger/<id>/entries` —— 路径带 id,
* 没法在静态表里穷举)。缓存键也用完整路径,天然互不冲突。
*/
export async function loadPath<T>(cacheKey: string, path: string, parse: (json: string) => T,
sink: (data: T, fromCache: boolean) => void,
onNote?: (note: string) => void): Promise<void> {
await FbModuleRepo.inst().load(cacheKey, path, (r: FbModuleLoad): void => {
let data: T;
try {
data = parse(r.json);
} catch (err) {
hilog.error(DOMAIN, TAG, '解析 %{public}s 失败:%{public}s', cacheKey, JSON.stringify(err));
return;
}
if (onNote !== undefined && r.error !== '') {
onNote(r.error);
}
sink(data, r.fromCache);
});
}
/** 模块 → 接口路径 */
export const FB_MODULE_PATH: Record<string, string> = {
'home': '/api/v1/home',
'calendar': '/api/v1/calendar',
'days': '/api/v1/days',
'tasks': '/api/v1/tasks',
'trips': '/api/v1/trips',
'ledger': '/api/v1/ledger',
'resources': '/api/v1/resources',
'books': '/api/v1/books',
'notes': '/api/v1/notes',
'notifications': '/api/v1/notifications',
'kin': '/api/v1/kin',
};
/**
* 装配一个模块:读缓存 + 拉网络,每次拿到**解析后**的数据回调。
*
* @param name 模块名
* @param parse JSON → 端上类型的解析函数
* @param sink 数据到位回调(会被调 1~2 次:缓存一次、网络一次)
* @param onNote 可选:提示文案(同步失败/未登录时给页面显示一行小字)
*/
export async function loadModule<T>(name: FbModuleName, parse: (json: string) => T,
sink: (data: T, fromCache: boolean) => void,
onNote?: (note: string) => void): Promise<void> {
const path: string = FB_MODULE_PATH[name] !== undefined ? FB_MODULE_PATH[name] : '';
if (path === '') {
hilog.warn(DOMAIN, TAG, '模块 %{public}s 没有配路径', name);
return;
}
await FbModuleRepo.inst().load(name, path, (r: FbModuleLoad): void => {
// ⚠️ 解析失败**不能抛**:缓存/接口的形状变了(或确实是空),
// 抛出去会让页面白屏。这里给个空数组兜住,并留一条日志。
let data: T;
try {
data = parse(r.json);
} catch (err) {
hilog.error(DOMAIN, TAG, '解析 %{public}s 失败:%{public}s', name, JSON.stringify(err));
return;
}
if (onNote !== undefined && r.error !== '') {
onNote(r.error);
}
sink(data, r.fromCache);
});
}
+160
View File
@@ -0,0 +1,160 @@
/**
* 家书 FamilyBook · 模块数据仓库(鸿蒙端)
*
* 一份代码服务**所有**业务模块(日历/任务/纪念日/旅程/账本/资源/藏书/笔记/通知/首页)。
*
* 🔴 为什么不做成"每模块一个 Repo":
* 观影和名册各写一套是因为它们**有本地表、要落库、要做增量同步**。
* 而其余模块目前都是"**拉一次、展示、缓存 JSON**"——
* 为它们各写一个 `xxxRepo` 只会得到 10 份 90% 相同的代码。
* ⇒ 抽一个通用仓库:**请求 → 缓存到本地库(一张 `module_cache` 表)→ 给 UI 解析后的对象**。
* ⚠️ 将来某个模块要"离线可写/增量同步"(比如任务打勾、账本记账),
* 那时再把它单独拆出来落实体表 —— **不要提前抽象**。
*
* 数据流(与观影/名册同一套骨架):
* `load()` → 先给**本地缓存**(快、断网可用)→ 再拉接口 → 覆盖缓存 → 再给一次。
*/
import { relationalStore } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbDb, FbRow } from './FbDb';
import { FbApiOptions, FbApiResult, fbApi } from './FbHttp';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbModuleRepo';
/** 模块名(同时也是缓存键) */
export type FbModuleName =
| 'home' | 'calendar' | 'days' | 'tasks' | 'trips'
| 'ledger' | 'resources' | 'books' | 'notes' | 'notifications' | 'kin';
/** 一次加载的结果 */
export interface FbModuleLoad {
/** 原始 JSON 字符串(空 = 没有数据) */
json: string;
/** 这份数据来自本地缓存还是网络 */
fromCache: boolean;
/** 网络失败原因(空 = 没失败;来自缓存时也可能非空) */
error: string;
}
/** 加载回调(先缓存、后网络,会调两次) */
export type FbModuleSink = (r: FbModuleLoad) => void;
export class FbModuleRepo {
private static _inst: FbModuleRepo | null = null;
private db: FbDb = FbDb.inst();
static inst(): FbModuleRepo {
if (FbModuleRepo._inst === null) {
FbModuleRepo._inst = new FbModuleRepo();
}
return FbModuleRepo._inst;
}
/**
* 加载一个模块的数据。
*
* ⚠️ **先给缓存、再给网络**(两次回调):这是"本地优先"的关键 ——
* 等网络回来才渲染 = 转圈好几秒,断网则全白。
*
* ⚠️ 网络失败**不清缓存**、也不抛异常:把 `error` 交给调用方,
* 让它决定是"提示一行小字"还是"忽略"(多数情况应忽略,
* 因为用户手上还有缓存数据可用)。
*/
/**
* @param name 缓存键(**不限于预定义模块**:两级接口用完整路径当键,
* 如 'ledger_detail_1')。
*/
async load(name: string, path: string, sink: FbModuleSink): Promise<void> {
// ① 本地缓存(若还没读过本地库,`readCache` 会返回空串,不影响)
const cached: string = await this.readCache(name);
if (cached !== '') {
sink({ json: cached, fromCache: true, error: '' });
}
// ② 拉接口
const token: string = this.token();
if (token === '') {
// 未登录:只能给缓存(可能为空)
sink({ json: cached, fromCache: true, error: '未登录,显示的是本地数据' });
return;
}
const opts: FbApiOptions = { method: 'GET', token: token };
const res: FbApiResult = await fbApi(path, opts);
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '加载 %{public}s 失败:%{public}s', name, res.error);
sink({ json: cached, fromCache: true, error: res.error });
return;
}
// ③ 写缓存 + 再给一次
await this.writeCache(name, res.data);
sink({ json: res.data, fromCache: false, error: '' });
}
/** 读缓存 */
private async readCache(name: string): Promise<string> {
try {
if (!this.db.isReady()) {
return '';
}
const row: FbRow | null = await this.db.queryOne(
'SELECT payload FROM module_cache WHERE name = ?', [name]);
return row !== null ? row.strOr('payload', '') : '';
} catch (err) {
hilog.warn(DOMAIN, TAG, '读缓存 %{public}s 失败:%{public}s', name, JSON.stringify(err));
return '';
}
}
/** 写缓存 */
private async writeCache(name: string, payload: string): Promise<void> {
if (payload === '') {
return;
}
try {
await this.db.exec(
'INSERT OR REPLACE INTO module_cache (name, payload, fetched_at) VALUES (?,?,?)',
[name, payload, Date.now()]);
} catch (err) {
hilog.warn(DOMAIN, TAG, '写缓存 %{public}s 失败:%{public}s', name, JSON.stringify(err));
}
}
/** 缓存时间(0 = 没有) */
async cachedAt(name: string): Promise<number> {
try {
const row: FbRow | null = await this.db.queryOne(
'SELECT fetched_at FROM module_cache WHERE name = ?', [name]);
return row !== null ? row.num('fetched_at', 0) : 0;
} catch (err) {
return 0;
}
}
/** 令牌(延迟取,避免仓库初始化时 Store 还没就绪) */
private token(): string {
// ⚠️ 这里直接 import 常量会形成循环依赖风险,改为运行时读
return FB_MODULE_TOKEN.get();
}
}
/**
* 令牌读取口(由 `FbTokenStore` 在启动时注入)。
*
* ⚠️ 为什么不直接 `import { FB_TOKEN_STORE }`:
* `FbTokenStore` 与仓库之间容易形成循环 import(它俩将来都会用到 `FbDb`)。
* 用一个**可注入的读取器**把依赖方向固定成"单向"。
*/
class FbModuleToken {
private reader: () => string = (): string => '';
set(fn: () => string): void {
this.reader = fn;
}
get(): string {
return this.reader();
}
}
export const FB_MODULE_TOKEN: FbModuleToken = new FbModuleToken();
+890
View File
@@ -0,0 +1,890 @@
/**
* 家书 FamilyBook · 观影仓库(鸿蒙端)
*
* 「接口 → 本地库 → UI」链路里的**枢纽**:
* · `sync()` —— 从接口拉数据、写进本地库(拉);
* · `list()` —— 从本地库读出来给 UI(**UI 只读这里,永不直接读接口**)。
*
* 🔴 这一层的存在意义(本地优先的核心):
* UI 调 `list()` 时**不关心网络**——有网没网都读同一份本地数据。
* 于是"断网可用"+"卡片零等待"这两件事是**天然成立**的,
* 而不是靠"缓存命中率"侥幸。
*
* 🔴 数据形状的真相(决定了这里的写法):
* 「观影」在服务端**不是一张表**,而是 6 张表组合出的视图:
* events(kind='movie') + movie_details + cinemas + movie_reviews
* + event_attendees + persons
* ⇒ 同步时要把这几张**分别落库**(按实体),读取时再 JOIN 拼回观影页要的样子。
* ⚠️ 别把接口返回的扁平结构直接存成一张 `movies` 表 ——
* 那样同一条日程会在「观影」和「日程」里各存一份、互相打架。
*/
import { relationalStore } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbDb, FbRow } from './FbDb';
import { FbApiOptions, FbApiResult, fbApi } from './FbHttp';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbMovieRepo';
/**
* 同步进度打点(**临时诊断用**,定位完删除)。
*
* ⚠️ 必须是**模块级的 `let`**(在 class **外面**):
* 同步会长时间不返回,需要知道它卡在哪一步。
* 这台设备的 hilog 缓冲区读不出内容(本项目多次确认),
* 所以用"全局变量 + 界面定时读取"的方式来观测。
* ⚠️ 它**不是** `@Local`,改它不会触发 UI 刷新 ——
* 界面那侧靠 setInterval 主动读、赋给自己的 @Local 才会刷新。
*/
export let FB_SYNC_PROGRESS: string = '';
/**
* 每批写入的行数。
*
* 🔴 取 20 是**实测**结果:一次塞 200 行时 `batchInsert` 卡死不返回;
* 分批到 20 行/批能正常完成。别随手改大 —— 大了就会重现卡死。
*/
const FB_BATCH_SIZE: number = 20;
/**
* 观影列表项(**UI 用的形状**,不是数据库形状)。
*
* ⚠️ 字段与服务端 `Movie::all()` 的返回对齐(已实测过真实响应),
* 这样从"接口 → 本地 → UI"一路不改名字,排查时一眼对得上。
*/
export interface FbMovieListItem {
id: number;
title: string;
/** 观影时间('YYYY-MM-DD HH:mm:ss') */
startsAt: string;
endsAt: string;
note: string;
/** 海报相对键(没有则空串,UI 画占位) */
posterKey: string;
cinemaName: string;
cinemaColor: string;
/** 同看人数 */
attendeeCount: number;
/** 评价条数 */
reviewCount: number;
/** 平均分(无人评价时为 0) */
reviewAvg: number;
}
/** 同步结果(供 UI 显示"刚更新了几条") */
/**
* 一家影院(观影模块的一部分,不是独立模块)。
*
* ⚠️ 与 Web 端一致:「影院」**不占抽屉里的位置** —— 它是观影功能的子页面。
*/
export interface FbCinemaItem {
id: number;
/** 影院名,如「横店电影城」 */
name: string;
/** 场所,如「东明县京都大润发」 */
place: string;
address: string;
/** 主题色(Web 端每家影院一个色,便于在列表里区分) */
color: string;
/** 在这家看过几部 */
movieCount: number;
}
export interface FbSyncResult {
ok: boolean;
/** 拉到并写进本地的条数 */
saved: number;
/** 失败原因(ok=false 时有值) */
error: string;
}
/** 观影详情(点卡片后展示) */
export interface FbMovieDetail {
ok: boolean;
error: string;
id: number;
title: string;
startsAt: string;
endsAt: string;
note: string;
/** 剧情简介(movie_details.info) */
info: string;
posterKey: string;
/** 票根图(movie_details.ticket_key) */
ticketKey: string;
cinemaName: string;
cinemaPlace: string;
cinemaAddress: string;
cinemaColor: string;
/** 我的评分(0 = 没评)。⚠️ 本版恒为 0:详情接口不返回"我的评价" */
myRating: number;
/** 我的评价正文。⚠️ 同上 */
myReview: string;
/**
* 全体评价条数 / 平均分。
*
* ⚠️ **详情接口不返回这两个值**(实测确认:那是列表侧
* `MovieReview::attachStats()` 算出来附上去的)。
* ⇒ 由调用方从**列表项**(本地库里存的)带进来,见 `detail(id, token, listItem)`。
* 详情页要显示"评分"时用这个,别指望接口给。
*/
reviewCount: number;
reviewAvg: number;
/** 同看的人 */
attendees: FbMovieAttendeeRow[];
/** 照片(相对键) */
photos: string[];
}
/**
* 观影仓库。
*
* ⚠️ 与 FbDb 一样用单例:仓库无状态(数据都在库里),
* 但保持单例便于将来加内存缓存时行为一致。
*/
export class FbMovieRepo {
private db: FbDb = FbDb.inst();
/**
* 从服务端同步观影数据到本地库。
*
* 流程:拉接口 → 拆信封 → **事务内**分别写入 events / movie_details /
* cinemas / persons / event_attendees → 更新水位线。
*
* ⚠️ 写库必须包在事务里(见 FbDb.transaction 的说明):
* 半途失败会留下"写了一半"的库,而水位线一旦推进,
* 缺的那部分就永远不会再被拉下来。
*
* ⚠️ 影院(cinemas)走**独立接口** `/api/v1/cinemas`,不从观影列表里推:
* 实测观影列表只返回 `cinema_name` / `cinema_color`(给列表显示用),
* **不返回 `cinema_id`** —— 没有主键就建不了 cinemas 表。
* (`cinema_id` 只在**观影详情** `Movie::find()` 里返回。)
* ⇒ 影院是独立的实体,就该用它的独立接口同步。
*
* @param token API 令牌
*/
async sync(token: string): Promise<FbSyncResult> {
// ---- ① 拉影院(先拉,供观影行的影院名反查 id)----
// ⚠️ 对象字面量必须有显式类型(ArkTS 的 arkts-no-untyped-obj-literals):
// 写在调用实参位置的裸字面量不会靠参数类型"渗入"推断。
FB_SYNC_PROGRESS = '1/6 拉取影院…';
const cinemaOpts: FbApiOptions = { method: 'GET', token: token };
const cinemaRes: FbApiResult = await fbApi('/api/v1/cinemas', cinemaOpts);
let cinemas: FbCinemaRow[] = [];
if (cinemaRes.ok) {
cinemas = parseCinemaRows(cinemaRes.data);
} else {
hilog.warn(DOMAIN, TAG, '影院同步失败(不影响观影):%{public}s', cinemaRes.error);
}
// ---- ② 拉观影列表 ----
FB_SYNC_PROGRESS = '2/6 拉取观影列表(影院 ' + cinemas.length.toString() + ' 家)…';
const movieQuery: Record<string, string> = { 'per_page': '200', 'page': '1' };
const movieOpts: FbApiOptions = { method: 'GET', query: movieQuery, token: token };
const res: FbApiResult = await fbApi('/api/v1/movies', movieOpts);
if (!res.ok) {
FB_SYNC_PROGRESS = '2/6 拉取失败:' + res.error;
const bad: FbSyncResult = { ok: false, saved: 0, error: res.error };
return bad;
}
const movies: FbMovieRow[] = parseMovieRows(res.data);
FB_SYNC_PROGRESS = '3/6 已解析 ' + movies.length.toString() + ' 条,准备落库…';
// ---- ③ 落库(一个事务内批量写完)----
// 🔴 这里必须用**同步**的 batchInsertSync / executeSync,且事务回调**不能 async**。
// 第一次写成 `await this.db.transaction(async () => { await batchInsert(...) })`,
// 结果是**同步函数永不返回**(界面卡在"正在同步…",实测 97 秒仍未结束),
// 而数据**其实已经写进库了** —— 典型的"事务跨 await 丢上下文"。
// 详见 FbDb.transaction() 的说明。
FB_SYNC_PROGRESS = '4/6 组装数据…';
let saved: number = 0;
// ---- 组装(纯内存操作,不碰数据库)----
const cinemaRows: relationalStore.ValuesBucket[] = [];
const cinemaIdByName: Map<string, number> = new Map<string, number>();
for (const c of cinemas) {
cinemaRows.push(this.cinemaBucket(c));
if (c.name !== '') {
cinemaIdByName.set(c.name, c.id);
}
}
const eventRows: relationalStore.ValuesBucket[] = [];
const detailRows: relationalStore.ValuesBucket[] = [];
const personRows: relationalStore.ValuesBucket[] = [];
const attendeeRows: relationalStore.ValuesBucket[] = [];
const now: number = Date.now();
for (const m of movies) {
const cid: number = cinemaIdByName.get(m.cinemaName) ?? 0;
eventRows.push(this.eventBucket(m, now));
detailRows.push(this.detailBucket(m, cid, now));
for (const a of m.attendees) {
personRows.push(this.personBucket(m, a, now));
attendeeRows.push(this.attendeeBucket(m, a, now));
}
saved++;
}
// ---- 写库 ----
// 🔴🔴 这里**刻意不用显式事务**,原因记在下面(很重要的一个踩坑结论)。
//
// 排查过程:
// · 第一版(逐条 `await exec`):数据能写进去,但**极慢**(1~2 分钟),
// 且 `sync()` 不返回;
// · 第二版(老事务 API `beginTransaction` + `commit`):
// 出现**诡异矛盾** —— 界面显示"共 200 部"(数据在),
// 但 `SELECT COUNT(*)` = **0**,且 Promise 永不返回。
// ⇒ 老事务绑在调用栈上,中间有 `await` 就丢上下文,
// `commit()` 变空操作,数据留在未提交状态。
// · 第三版(`createTransaction()` 对象 + `batchInsertSync`):
// **卡在 createTransaction 本身**,几分钟不返回;
// · 第四版(`createTransaction()` 对象 + `await batchInsert`):
// 同样卡住。
// ⇒ 结论:**这些事务 API 在本机 SDK/设备组合上不可用**(至少我未能找到可用形态)。
// 继续硬啃事务的性价比很低 —— 于是**改成无事务的批量写入**:
// `batchInsert` 本身就把 N 次插入合成一次调用,**原子性由 SQLite 单条语句保证**
// (一条 INSERT ... VALUES (...),(...),(...) 要么全成功要么全失败)。
// ⚠️ 代价:跨表(events / movie_details / …)不再有整体原子性。
// 但这里的写入是**幂等**的(`INSERT OR REPLACE` 语义 + 每次都全量重拉),
// 所以"写到一半崩了"只会导致下次同步重来,不会产生脏数据。
// **幂等 + 全量重拉,比事务更适合这里**(见下方说明)。
//
// ⚠️🔴 **一次 `batchInsert` 不能塞太多行**:
// 实测把 200 行一次交给 `batchInsert`,**它不返回**(几分钟卡死),
// 进度停在"5/6 写入库(ev 200 / 详情 200 / 人 412 / 同看 412)…"
// —— 数据组装完全正确,就是这一次调用回不来。
// (`batchInsertSync` 同样卡,两者都试过。)
// ⇒ 改成**分小批**(每批 `BATCH` 行)+ 每批之间 `await` 一次让出主线程。
// 分批同时解决了"单次调用数据量过大"和"长时间霸占主线程"两个问题。
// ⚠️ 代价:跨批不再有原子性。但本流程是**幂等 + 全量重拉**
// (`INSERT OR REPLACE`、每次都从头拉 200 条),
// "写到一半崩了"只会让下次同步重来,不会产生脏数据。
FB_SYNC_PROGRESS = '5/6 写入库(ev ' + eventRows.length.toString() +
' / 详情 ' + detailRows.length.toString() +
' / 人 ' + personRows.length.toString() +
' / 同看 ' + attendeeRows.length.toString() + ')…';
const store: relationalStore.RdbStore = this.db.require();
await this.insertInBatches(store, 'cinemas', cinemaRows);
// ⚠️ 顺序:events 是主表,其余引用它的 id(便于排查,非外键强制)
await this.insertInBatches(store, 'events', eventRows);
FB_SYNC_PROGRESS = '5/6 events 已写入(' + eventRows.length.toString() + ' 条)…';
await this.insertInBatches(store, 'movie_details', detailRows);
// 🔴 `persons` 是跨模块共用表(名册 / 观影都写),不能先清空
// ⇒ 靠 REPLACE 语义更新(默认 batchInsert 在 PK 冲突时是 IGNORE,
// 已存在的人永远不更新)。详见 FbPersonRepo 里的说明。
await this.insertInBatches(store, 'persons', personRows, true);
await this.insertInBatches(store, 'event_attendees', attendeeRows);
FB_SYNC_PROGRESS = '6/6 写入完成,更新水位线…';
await this.setWatermark('events', nowIso());
await this.setWatermark('cinemas', nowIso());
hilog.info(DOMAIN, TAG, '观影同步完成:影片 %{public}d 条、影院 %{public}d 家',
saved, cinemas.length);
const done: FbSyncResult = { ok: true, saved: saved, error: '' };
return done;
}
/**
* 分批批量插入。
*
* 🔴 为什么要分批(很重要的实测结论):
* 把 200 行一次交给 `batchInsert` / `batchInsertSync`,**两者都不返回**
* (2032-10-12 实测卡死几分钟,进度停在写入那一步)。
* 分批后每批只有 `BATCH_SIZE` 行,实测能正常完成。
*
* ⚠️ 每批之间 `await` 一次:既是 `batchInsert` 本身要 await,
* 也顺便把主线程让出去,避免长时间独占(UI 不掉帧)。
*
* @param table 目标表
* @param rows 待写入的行(可为空 → 直接返回)
*/
private async insertInBatches(store: relationalStore.RdbStore, table: string,
rows: relationalStore.ValuesBucket[], replace: boolean = false): Promise<void> {
if (rows.length === 0) {
return;
}
for (let i = 0; i < rows.length; i += FB_BATCH_SIZE) {
const chunk: relationalStore.ValuesBucket[] = rows.slice(i, i + FB_BATCH_SIZE);
if (replace) {
await store.batchInsertWithConflictResolution(
table, chunk, relationalStore.ConflictResolution.ON_CONFLICT_REPLACE);
} else {
await store.batchInsert(table, chunk);
}
}
}
/** 组装一家影院的 ValuesBucket */
private cinemaBucket(c: FbCinemaRow): relationalStore.ValuesBucket {
const b: relationalStore.ValuesBucket = {};
b['id'] = c.id;
b['family_id'] = c.familyId;
b['place'] = c.place;
b['name'] = c.name;
b['address'] = c.address;
b['color'] = c.color;
b['created_at'] = c.createdAt;
b['updated_at'] = c.updatedAt;
b['deleted_at'] = c.deletedAt;
b['synced_at'] = Date.now();
b['dirty'] = 0;
return b;
}
/**
* 组装观影记录 → events 表的 bucket。
*
* ⚠️ 写成「列名 → 值」的映射(`ValuesBucket`)而不是 SQL 占位符:
* `batchInsert` 要的就是这个形式,不必再拼 SQL 字符串。
* ⚠️ 用对象下标赋值(`b['id'] = ...`)而不是 `b.id = ...`:
* `ValuesBucket = Record<string, ValueType>`,写属性名在 ArkTS 里
* 会被当成"访问未声明属性"报错(arkts-no-props-by-index 的镜像规则)。
*/
private eventBucket(m: FbMovieRow, now: number): relationalStore.ValuesBucket {
const b: relationalStore.ValuesBucket = {};
b['id'] = m.id;
b['family_id'] = m.familyId;
b['creator_id'] = m.creatorId;
b['kind'] = 'movie';
b['title'] = m.title;
b['starts_at'] = m.startsAt;
b['ends_at'] = m.endsAt;
b['location'] = m.location;
b['note'] = m.note;
b['source_ref'] = m.sourceRef;
b['created_at'] = m.createdAt;
b['updated_at'] = m.updatedAt;
b['deleted_at'] = m.deletedAt;
b['review_count'] = m.reviewCount;
b['review_avg'] = m.reviewAvg;
b['synced_at'] = now;
b['dirty'] = 0;
return b;
}
/** 组装观影详情 → movie_details 的 bucket */
private detailBucket(m: FbMovieRow, cinemaId: number, now: number): relationalStore.ValuesBucket {
const b: relationalStore.ValuesBucket = {};
b['event_id'] = m.id;
b['info'] = m.movieInfo;
b['poster_key'] = m.posterKey;
b['ticket_key'] = m.ticketKey;
b['cinema_id'] = cinemaId;
b['douban_meta'] = m.doubanMeta;
b['updated_at'] = m.updatedAt;
b['deleted_at'] = m.deletedAt;
b['synced_at'] = now;
b['dirty'] = 0;
return b;
}
/** 组装同看人 → persons 的 bucket */
private personBucket(m: FbMovieRow, a: FbMovieAttendeeRow, now: number): relationalStore.ValuesBucket {
const b: relationalStore.ValuesBucket = {};
b['id'] = a.id;
b['family_id'] = m.familyId;
b['kind'] = 'member';
b['name'] = a.name;
b['nickname'] = a.myNickname;
b['gender'] = '';
b['birthday'] = '';
b['avatar'] = a.avatar;
b['note'] = '';
b['created_at'] = '';
b['updated_at'] = nowIso();
b['deleted_at'] = null;
b['synced_at'] = now;
b['dirty'] = 0;
return b;
}
/**
* 组装同看关系 → event_attendees 的 bucket。
*
* ⚠️ 接口里 `attendees[].id` 实测是 **persons.id**(服务端 SQL 是
* `SELECT a.event_id, p.id, ...`),**不是**这张关联表自己的行 id
* (接口根本没返回那个)。
* ⇒ 本地这张表用**复合主键 (event_id, person_id)**(见 FbSchema),
* 这里只填两个外键。
*/
private attendeeBucket(m: FbMovieRow, a: FbMovieAttendeeRow, now: number): relationalStore.ValuesBucket {
const b: relationalStore.ValuesBucket = {};
b['event_id'] = m.id;
b['person_id'] = a.id;
b['position'] = 0;
b['updated_at'] = nowIso();
b['deleted_at'] = null;
b['synced_at'] = now;
b['dirty'] = 0;
return b;
}
/**
* 拉一条观影的**详情**(点卡片时用)。
*
* 🔴 为什么详情要**单独拉接口**、不直接用本地库拼:
* 列表接口只给了"列表要显示的那几个字段"(片名/时间/影院/评分汇总)。
* 详情还要:剧情简介、票根、影院地址、**我的评价正文**、照片列表
* —— 这些列表接口都没返回(列表塞这些会让响应大很多)。
* ⇒ 列表读本地(快、断网可看),详情读接口(要什么拉什么)。
*
* ⚠️ 详情**不落库**:它是一次性展示,且含"我的评价"这种**因人而异**的数据,
* 落库反而要处理"这条评价属于谁"。
*/
async detail(id: number, token: string, listItem?: FbMovieListItem): Promise<FbMovieDetail> {
const opts: FbApiOptions = { method: 'GET', token: token };
const res: FbApiResult = await fbApi('/api/v1/movies/' + id.toString(), opts);
if (!res.ok) {
return emptyDetail(id, res.error);
}
const d: FbMovieDetail = parseDetail(id, res.data);
// ⚠️ 评分汇总接口不给,从列表项带过来(见 FbMovieDetail.reviewCount 的说明)
if (listItem !== undefined) {
d.reviewCount = listItem.reviewCount;
d.reviewAvg = listItem.reviewAvg;
}
return d;
}
/**
* 从本地库读观影列表(**UI 的唯一数据来源**)。
*
* 🔴 这就是"本地优先"落到代码上的样子:读的是本地库,
* 没有网络请求、没有 loading 转圈。
*
* JOIN 三张表把观影页要的形状拼出来(与服务端 Movie::all 的语义一致):
* events(kind='movie') + movie_details + cinemas
*/
async list(limit: number = 100, keyword: string = ''): Promise<FbMovieListItem[]> {
const kw: string = keyword.trim();
let sql: string =
'SELECT e.id AS id, ' +
' e.title AS title, ' +
' e.starts_at AS starts_at, ' +
' e.ends_at AS ends_at, ' +
' e.note AS note, ' +
' d.poster_key AS poster_key, ' +
' c.name AS cinema_name, ' +
' c.color AS cinema_color, ' +
' (SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id' +
' AND a.deleted_at IS NULL) AS attendee_count, ' +
' e.review_count AS review_count, ' +
' e.review_avg AS review_avg ' +
' FROM events e ' +
' LEFT JOIN movie_details d ON d.event_id = e.id ' +
' LEFT JOIN cinemas c ON c.id = d.cinema_id ' +
" WHERE e.kind = 'movie' AND e.deleted_at IS NULL ";
const args: relationalStore.ValueType[] = [];
if (kw !== '') {
sql += ' AND (e.title LIKE ? OR c.name LIKE ?) ';
args.push('%' + kw + '%', '%' + kw + '%');
}
sql += ' ORDER BY e.starts_at DESC, e.id DESC LIMIT ? ';
args.push(limit);
const rows: FbRow[] = await this.db.queryRows(sql, args);
const out: FbMovieListItem[] = [];
for (const r of rows) {
out.push({
id: r.num('id', 0),
title: r.strOr('title', ''),
startsAt: r.strOr('starts_at', ''),
endsAt: r.strOr('ends_at', ''),
note: r.strOr('note', ''),
posterKey: r.strOr('poster_key', ''),
cinemaName: r.strOr('cinema_name', ''),
cinemaColor: r.strOr('cinema_color', ''),
attendeeCount: r.num('attendee_count', 0),
reviewCount: r.num('review_count', 0),
reviewAvg: r.num('review_avg', 0)
});
}
return out;
}
/** 本地已有的观影条数(判断"要不要首次同步"用) */
async localCount(): Promise<number> {
return this.db.scalar("SELECT COUNT(*) FROM events WHERE kind = 'movie' AND deleted_at IS NULL");
}
/** 读同步水位线 */
/**
* 影院列表(**从本地库读**)。
*
* 🔴 为什么从库里读、而不是现拉 `/api/v1/cinemas`:
* 影院表本来就在观影同步里一起落库了(`events` + `cinemas` 同一批),
* 现拉等于"已经有的数据再要一次"。
* ⚠️ 但如果本地是空的(没同步过),调用方应自己兜底 —— 这里不替你决定。
*/
async cinemaList(): Promise<FbCinemaItem[]> {
const rows: FbRow[] = await this.db.queryRows(
'SELECT id, name, place, address, color FROM cinemas'
+ ' WHERE deleted_at IS NULL ORDER BY name ASC', []);
const out: FbCinemaItem[] = [];
for (const r of rows) {
const it: FbCinemaItem = {
id: r.num('id', 0),
name: r.strOr('name', ''),
place: r.strOr('place', ''),
address: r.strOr('address', ''),
color: r.strOr('color', '') !== '' ? r.strOr('color', '') : '#7A4E8C',
// 这一家看过几部(从观影记录数)—— 与 Web 端影院页的口径一致
movieCount: await this.db.scalar(
'SELECT COUNT(*) FROM events e JOIN movie_details d ON d.event_id = e.id'
+ ' WHERE d.cinema_id = ? AND e.deleted_at IS NULL', [r.num('id', 0)]),
};
out.push(it);
}
return out;
}
async watermark(table: string): Promise<string> {
const row: FbRow | null = await this.db.queryOne(
'SELECT last_sync_at FROM sync_state WHERE table_name = ?', [table]);
return row !== null ? row.strOr('last_sync_at', '') : '';
}
/** 写同步水位线(**只在整批都写成功后**才调用) */
private async setWatermark(table: string, at: string): Promise<void> {
await this.db.exec(
'INSERT OR REPLACE INTO sync_state (table_name, last_sync_at, last_ok_at) VALUES (?,?,?)',
[table, at, Date.now()]
);
}
// ---- 单例 ----
private static instance: FbMovieRepo | null = null;
static inst(): FbMovieRepo {
if (FbMovieRepo.instance === null) {
FbMovieRepo.instance = new FbMovieRepo();
}
return FbMovieRepo.instance;
}
}
/** 当前时间('YYYY-MM-DD HH:mm:ss',与服务端 DATETIME 同格式) */
function nowIso(): string {
const d: Date = new Date();
const p = (n: number): string => (n < 10 ? '0' + n : String(n));
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' +
p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
}
// ============================================================
// 接口响应的解析(服务端 → 本地结构)
// ============================================================
/** 服务端 `Movie::all()` 的一行(原始 JSON 形状) */
interface FbApiMovieRow {
id?: number;
title?: string;
starts_at?: string;
ends_at?: string;
note?: string | null;
poster_key?: string | null;
cinema_name?: string | null;
cinema_color?: string | null;
attendee_count?: number;
review_count?: number;
review_avg?: number | null;
attendees?: FbApiAttendee[];
}
/** 接口里的一个同看人 */
interface FbApiAttendee {
event_id?: number;
id?: number;
name?: string;
avatar?: string | null;
avatar_color?: string | null;
my_nickname?: string | null;
}
/** 解析后的观影行(本地内部用,字段已转成强类型) */
interface FbMovieRow {
id: number;
familyId: number;
creatorId: number;
title: string;
startsAt: string;
endsAt: string;
location: string;
note: string;
sourceRef: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
movieInfo: string;
posterKey: string;
ticketKey: string;
/** ⚠️ 观影**列表**接口不返回它(只有详情接口返回)⇒ 这里由"影院名反查"补上 */
cinemaName: string;
cinemaColor: string;
doubanMeta: string;
reviewCount: number;
reviewAvg: number;
attendees: FbMovieAttendeeRow[];
}
/** 解析后的一家影院(对应 `Cinema::all()` 的行) */
interface FbCinemaRow {
id: number;
familyId: number;
place: string;
name: string;
address: string;
color: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
/** 接口里的一家影院(原始 JSON 形状,`c.*` + movie_count) */
interface FbApiCinemaRow {
id?: number;
family_id?: number;
place?: string;
name?: string;
address?: string | null;
color?: string;
created_at?: string;
updated_at?: string;
deleted_at?: string | null;
}
/** 解析后的同看人 */
export interface FbMovieAttendeeRow {
id: number;
name: string;
avatar: string;
/** ⚠️ 接口返回的原始头像色(`avatar_color`)—— 之前建表时没存,
* 浮层要显示头像色所以带上;本地库里没这一列,列表页不用它。
* 这里做成可选(`?`)避免影响已有的构造处。 */
avatarColor?: string;
myNickname: string;
}
/**
* 把接口 JSON 解析成内部行。
*
* ⚠️ 每一步都做**防御性判空**:服务端字段可能缺(老数据)、可能是 null。
* ArkTS 里 `obj.a.b` 只要中间一环是 undefined 就直接崩,
* 而崩在"解析接口响应"里,用户看到的是白屏、排查时看不出是哪个字段。
*/
function parseMovieRows(json: string): FbMovieRow[] {
const out: FbMovieRow[] = [];
if (json === '') {
return out;
}
let arr: FbApiMovieRow[] = [];
try {
arr = JSON.parse(json) as FbApiMovieRow[];
} catch (err) {
hilog.error(DOMAIN, TAG, '观影列表解析失败:%{public}s', JSON.stringify(err));
return out;
}
if (!Array.isArray(arr)) {
return out;
}
for (const m of arr) {
const id: number = numOf(m.id);
if (id <= 0) {
continue; // 没有 id 的行没法落库(主键)
}
out.push({
id: id,
familyId: 1, // 家庭固定 1(服务端 Api::me() 返回的那个)
creatorId: 0,
title: strOf(m.title),
startsAt: strOf(m.starts_at),
endsAt: strOf(m.ends_at),
location: '',
note: strOf(m.note),
sourceRef: '',
createdAt: '',
updatedAt: nowIso(),
deletedAt: null,
movieInfo: '',
posterKey: strOf(m.poster_key),
ticketKey: '',
cinemaName: strOf(m.cinema_name),
cinemaColor: strOf(m.cinema_color),
doubanMeta: '',
reviewCount: numOf(m.review_count),
reviewAvg: numOf(m.review_avg),
attendees: parseAttendees(m.attendees)
});
}
return out;
}
/** 解析影院列表(`/api/v1/cinemas`) */
function parseCinemaRows(json: string): FbCinemaRow[] {
const out: FbCinemaRow[] = [];
if (json === '') {
return out;
}
let arr: FbApiCinemaRow[] = [];
try {
arr = JSON.parse(json) as FbApiCinemaRow[];
} catch (err) {
hilog.error(DOMAIN, TAG, '影院列表解析失败:%{public}s', JSON.stringify(err));
return out;
}
if (!Array.isArray(arr)) {
return out;
}
for (const c of arr) {
const id: number = numOf(c.id);
if (id <= 0) {
continue;
}
out.push({
id: id,
familyId: numOf(c.family_id) > 0 ? numOf(c.family_id) : 1,
place: strOf(c.place),
name: strOf(c.name),
address: strOf(c.address),
color: strOf(c.color),
createdAt: strOf(c.created_at),
updatedAt: strOf(c.updated_at) !== '' ? strOf(c.updated_at) : nowIso(),
deletedAt: c.deleted_at !== undefined && c.deleted_at !== null ? c.deleted_at : null
});
}
return out;
}
/** 解析同看人列表 */
function parseAttendees(list?: FbApiAttendee[]): FbMovieAttendeeRow[] {
const out: FbMovieAttendeeRow[] = [];
if (list === undefined || !Array.isArray(list)) {
return out;
}
for (const a of list) {
const id: number = numOf(a.id);
if (id <= 0) {
continue;
}
out.push({
id: id,
name: strOf(a.name),
avatar: strOf(a.avatar),
// 头像色(接口的 `avatar_color`)—— 浮层显示头像要用
avatarColor: strOf(a.avatar_color),
myNickname: strOf(a.my_nickname)
});
}
return out;
}
/** 安全取字符串(null/undefined → 空串) */
function strOf(v: string | null | undefined): string {
return v !== null && v !== undefined ? v : '';
}
/** 安全取数字(null/undefined → 0) */
function numOf(v: number | null | undefined): number {
return v !== null && v !== undefined ? v : 0;
}
// ============================================================
// 观影详情的解析
// ============================================================
/** 服务端 `Movie::find()` 的原始 JSON 形状 */
interface FbApiMovieDetail {
id?: number;
title?: string;
starts_at?: string;
ends_at?: string;
note?: string | null;
info?: string | null;
poster_key?: string | null;
ticket_key?: string | null;
cinema_name?: string | null;
cinema_place?: string | null;
cinema_address?: string | null;
cinema_color?: string | null;
attendees?: FbApiAttendee[];
/**
* ⚠️ 照片是**对象数组**(`[{id, key}]`),不是字符串数组 ——
* 这是**实测**过的真实形状(读代码时我一度猜成 `string[]`,是错的)。
* 服务端 `Movie::photos()` 返回带 id 的对象,便于以后按 id 删除。
*/
photos?: FbApiPhoto[];
}
/** 详情里的一张照片 */
interface FbApiPhoto {
id?: number;
key?: string;
}
/** 空的详情(出错时用,避免调用方判 null) */
function emptyDetail(id: number, error: string): FbMovieDetail {
return {
ok: false, error: error, id: id, title: '', startsAt: '', endsAt: '', note: '',
info: '', posterKey: '', ticketKey: '', cinemaName: '', cinemaPlace: '',
cinemaAddress: '', cinemaColor: '', myRating: 0, myReview: '',
reviewCount: 0, reviewAvg: 0, attendees: [], photos: []
};
}
/**
* 解析详情响应。
*
* ⚠️ 每一步都要判空:详情字段很多是可选(老记录可能没填简介/票根/评价)。
* ArkTS 里 `obj.a.b` 中间任一环是 undefined 就崩,而崩在解析里
* 用户只看到白屏、排查时不知道是哪个字段。
*/
function parseDetail(id: number, json: string): FbMovieDetail {
if (json === '') {
return emptyDetail(id, '服务端没有返回详情');
}
let o: FbApiMovieDetail;
try {
o = JSON.parse(json) as FbApiMovieDetail;
} catch (err) {
hilog.error(DOMAIN, TAG, '观影详情解析失败:%{public}s', JSON.stringify(err));
return emptyDetail(id, '详情数据格式不对');
}
if (o === null || o === undefined) {
return emptyDetail(id, '详情是空的');
}
// 照片:接口给的是 `[{id, key}]`,这里只留相对键(界面按 key 拼图片地址)
const photos: string[] = [];
if (o.photos !== undefined && Array.isArray(o.photos)) {
for (const ph of o.photos) {
const k: string = strOf(ph.key);
if (k !== '') {
photos.push(k);
}
}
}
return {
ok: true,
error: '',
id: numOf(o.id) > 0 ? numOf(o.id) : id,
title: strOf(o.title),
startsAt: strOf(o.starts_at),
endsAt: strOf(o.ends_at),
note: strOf(o.note),
info: strOf(o.info),
posterKey: strOf(o.poster_key),
ticketKey: strOf(o.ticket_key),
cinemaName: strOf(o.cinema_name),
cinemaPlace: strOf(o.cinema_place),
cinemaAddress: strOf(o.cinema_address),
cinemaColor: strOf(o.cinema_color),
// ⚠️ 「我的评价」与「评分汇总」详情接口都**不返回**(实测确认)——
// 前者要拉子资源,后者由调用方从列表项带过来。
myRating: 0,
myReview: '',
reviewCount: 0,
reviewAvg: 0,
attendees: parseAttendees(o.attendees),
photos: photos
};
}
+864
View File
@@ -0,0 +1,864 @@
/**
* 家书 FamilyBook · 接口数据解析(鸿蒙端)
*
* 把服务端 `GET /api/v1/<模块>` 返回的 JSON 转成端上的展示类型(`model/Types`)。
*
* 🔴 为什么单独一个文件、而且要**照着真实响应写**:
* 服务端返回的是**数据库行**(几十个字段、命名是 SQL 风格:
* `starts_at` / `entry_count` / `is_public` / `deleted_at`…),
* 而端上 UI 要的是**为显示准备过的形状**(`dateRange` / `daysLeft` / `netCent`…)。
* 这层转换必须在端上做(服务端接口是给"通用 API 用户"的,
* 不该为了 App 的展示需要去改它)。
*
* ⚠️ 每个解析函数都**只依赖实测字段**,没实测过的字段不写。
* 想看形状就跑:`curl -H 'Authorization: Bearer <token>' .../api/v1/<模块>`
*/
import {
FbAccount, FbAnniversaryItem, FbBookItem, FbCalEvent, FbHomeData, FbKinChip,
FbKinGraph, FbKinPerson, FbMark,
FbLedgerBook, FbLedgerDetail, FbLedgerEntry, FbNoteItem, FbNotificationItem, FbResourceItem,
FbTaskItem, FbTripItem, FbWeather, FbStat, FbMemoryItem,
} from '../model/Types';
// ============================================================
// 小工具
// ============================================================
/** 安全转字符串(null/undefined → '') */
export function s(v: string | null | undefined): string {
return v !== null && v !== undefined ? v : '';
}
/** 安全转数字 */
export function n(v: number | null | undefined): number {
return v !== null && v !== undefined ? v : 0;
}
/** 安全取布尔(服务端用 0/1 表示) */
export function b(v: number | boolean | null | undefined): boolean {
if (typeof v === 'boolean') {
return v;
}
return n(v as number) !== 0;
}
/** 取数组(不是数组就返回空数组) */
export function arr<T>(v: T[] | null | undefined): T[] {
return Array.isArray(v) ? v : [];
}
/** 从 'YYYY-MM-DD HH:mm:ss' 里取日期部分 */
export function datePart(v: string): string {
const t: string = s(v);
const i: number = t.indexOf(' ');
return i > 0 ? t.substring(0, i) : t;
}
/** 从 'YYYY-MM-DD HH:mm:ss' 里取时间部分 'HH:mm' */
export function timePart(v: string): string {
const t: string = s(v);
const i: number = t.indexOf(' ');
if (i <= 0) {
return '';
}
return t.substring(i + 1, i + 6);
}
/** 'YYYY-MM-DD' → 'MM-DD' */
export function monthDay(v: string): string {
const t: string = datePart(v);
return t.length >= 10 ? t.substring(5, 10) : t;
}
/** 把金额(分)转成中文金额文案 */
export function money(cent: number): string {
const yuan: number = cent / 100;
const abs: number = Math.abs(yuan);
const sign: string = yuan < 0 ? '-' : '';
if (abs >= 10000) {
return sign + (abs / 10000).toFixed(2) + ' 万';
}
return sign + abs.toFixed(2);
}
/**
* 造一个人物 chip。
*
* ⚠️ `FbKinChip` 的字段是 `{id, shown, kinship, avatarColor, isAdmin}` ——
* **没有** `name` / `avatar`。之前按"想当然"写会直接编译不过。
* ⇒ 映射前先确认目标类型的真实字段。
*/
export function chip(shown: string, color: string, id: number = 0): FbKinChip {
const c: FbKinChip = {
id: id,
shown: s(shown),
kinship: '',
avatarColor: s(color) !== '' ? s(color) : '#A84A28',
isAdmin: false,
};
return c;
}
// ============================================================
// 原始行类型(只列用到的字段)
// ============================================================
interface RawCalEvent {
cal?: number; color?: string; calname?: string; title?: string;
date?: string; time?: string; end_time?: string; all_day?: boolean | number;
kind?: string; ref_id?: number; location?: string; note?: string;
repeat?: string; occ?: string;
}
interface RawTrip {
id?: number; title?: string; starts_at?: string; ends_at?: string;
location?: string | null; note?: string | null; cover_key?: string | null;
summary?: string | null; is_public?: number; attendee_count?: number;
node_count?: number; cities?: string[]; city?: string;
}
interface RawDay {
id?: number; title?: string; kind?: string; repeat_type?: string;
month?: number; day?: number; is_leap?: number;
person_id?: number | null; person_name?: string | null;
source?: string; born?: string | null;
}
interface RawTask {
id?: number; title?: string; status?: string; board_id?: number; category_id?: number | null;
note?: string | null; due_at?: string | null; done_at?: string | null;
assignee_id?: number | null; assignee_name?: string | null;
avatar?: string | null; avatar_color?: string | null;
category_name?: string | null; child_total?: number; child_done?: number;
comment_count?: number; attach_count?: number;
/** 看板名(`byStatus` 会 JOIN 出来;首页任务列表要用) */
board_name?: string | null;
/**
* 看板**派生色**(服务端算好的,v54 新增)。
*
* 🔴 用户 2026-10-08 报过:「首页近期任务,任务的文字和前面的圆点,
* 颜色并没有跟看板颜色一致」。
* ⚠️ 不能读 `board_color` —— 看板没设色时它是 `null`,
* 要走按名字派生的那套(同一个看板名永远同一个色,用户才记得住)。
*/
board_color_calc?: string | null;
}
/** 首页日子标记的原始行(`{word, kind}`) */
interface RawMark {
word?: string; kind?: string;
}
/** 往年今天的原始行(服务端 `Home::memories()` 给的那几列) */
interface RawMemory {
id?: number; title?: string; starts_at?: string; kind?: string;
years_ago?: number; n?: number;
}
interface RawTaskBoard {
id?: number; name?: string; color?: string; tasks?: RawTask[];
}
interface RawLedgerBook {
id?: number; name?: string; my_perm?: string | null; entry_count?: number;
share_perm?: string | null;
}
interface RawLedgerEntry {
id?: number; ledger_id?: number; kind?: string; title?: string;
amount_cents?: number; note?: string | null;
person_id?: number | null; person_name?: string | null;
person_nick?: string | null; avatar?: string | null; avatar_color?: string | null;
updated_at?: string; created_at?: string;
}
interface RawLedgerSummary {
asset?: number; debt?: number; net?: number; count?: number;
}
interface RawResource {
id?: number; title?: string; content?: string; owner_id?: number;
owner_name?: string | null; avatar?: string | null; avatar_color?: string | null;
owner_nick?: string | null; perm?: string | null;
created_at?: string; updated_at?: string;
}
interface RawBook {
id?: number; title?: string; author?: string | null; status?: string | null;
rating?: number | null; progress?: number | null; note_count?: number | null;
cover_key?: string | null; category?: string | null; publisher?: string | null;
}
interface RawNote {
id?: number; title?: string; is_shared?: number; updated_at?: string;
author_name?: string | null; avatar?: string | null; avatar_color?: string | null;
is_mine?: number; blocks?: RawNoteBlock[];
}
interface RawNoteBlock {
id?: number; kind?: string; content?: string; position?: number;
}
interface RawNotification {
id?: number; title?: string; body?: string; created_at?: string;
read_at?: string | null; actor_name?: string | null;
avatar?: string | null; avatar_color?: string | null;
}
// ============================================================
// 各模块解析
// ============================================================
/** 日历条目 → FbCalEvent */
export function parseCalendar(json: string): FbCalEvent[] {
return parseCalRows(asArray<RawCalEvent>(json));
}
/** 日历行的实际转换(`parseHome` 也要用,所以单独一个数组版) */
function parseCalRows(rows: RawCalEvent[]): FbCalEvent[] {
const out: FbCalEvent[] = [];
for (const r of rows) {
const e: FbCalEvent = {
id: n(r.ref_id),
// 🔴 v52:补上真实日历本 id / 结束时间 / 类型 / 备注
// (接口本来就给了,旧解析漏掉 → 新建日程没法指定往哪本写)
calId: n(r.cal),
title: s(r.title),
date: s(r.date),
time: s(r.time),
endTime: s(r.end_time),
calName: s(r.calname),
color: s(r.color) !== '' ? s(r.color) : '#A84A28',
allDay: b(r.all_day),
place: s(r.location),
note: s(r.note),
kind: s(r.kind)
};
out.push(e);
}
return out;
}
/** 旅程列表 → FbTripItem */
export function parseTrips(json: string): FbTripItem[] {
const rows: RawTrip[] = asArray<RawTrip>(json);
const out: FbTripItem[] = [];
for (const r of rows) {
const cities: string[] = arr<string>(r.cities);
const dayCount: number = daySpan(s(r.starts_at), s(r.ends_at));
const it: FbTripItem = {
id: n(r.id),
title: s(r.title),
// 副标题:城市 + 天数(与 Web 端 trips 列表口径一致)
sub: s(r.city) !== '' ? s(r.city) : cities.join(' · '),
dateRange: dateRangeText(s(r.starts_at), s(r.ends_at), dayCount),
nodeCount: n(r.node_count),
doneCount: 0,
};
out.push(it);
}
return out;
}
/** 纪念日 → FbAnniversaryItem */
export function parseDays(json: string): FbAnniversaryItem[] {
const rows: RawDay[] = asArray<RawDay>(json);
const out: FbAnniversaryItem[] = [];
const today: Date = new Date();
for (const r of rows) {
const m: number = n(r.month);
const d: number = n(r.day);
const people: FbKinChip[] = [];
if (n(r.person_id) > 0) {
people.push(chip(s(r.person_name), '#A84A28', n(r.person_id)));
}
const it: FbAnniversaryItem = {
id: n(r.id),
title: s(r.title),
date: pad2(m) + '-' + pad2(d),
daysLeft: daysUntil(today, m, d),
kind: kindCn(s(r.kind)),
people: people,
yearly: s(r.repeat_type) !== 'once',
};
out.push(it);
}
// 按"距今天数"升序(最近的在前)——纪念日列表页的默认排序
out.sort((a: FbAnniversaryItem, c: FbAnniversaryItem): number => a.daysLeft - c.daysLeft);
return out;
}
/** 任务(看板)→ 平铺的 FbTaskItem[] */
export function parseTasks(json: string): FbTaskItem[] {
const root: Record<string, Object> | null = asObject(json);
if (root === null) {
return [];
}
const boards: RawTaskBoard[] = arr<RawTaskBoard>(root['boards'] as RawTaskBoard[]);
const out: FbTaskItem[] = [];
for (const bd of boards) {
const tasks: RawTask[] = arr<RawTask>(bd.tasks);
for (const t of tasks) {
out.push(taskRow(t, s(bd.name), s(bd.color)));
}
}
return out;
}
/**
* 一行任务 → `FbTaskItem`(看板视图与首页列表**共用**)。
*
* 🔴 抽出来的理由:首页的 `/home` 直接给**扁平的任务数组**(它跨看板按状态取),
* 而任务页给的是"按看板分组"。两处各自写一遍转换 ⇒ 改一处漏一处
* (本项目已经吃过好几轮这个亏)。
*
* @param boardName 看板名(首页那批的看板名在行里的 `board_name`,由本函数内部兜底取)
* @param boardColor 看板色原文(可能为空 ⇒ 用派生色)
*/
function taskRow(t: RawTask, boardName: string, boardColor: string): FbTaskItem {
const due: string = s(t.due_at);
// 看板色优先级:行里算好的派生色 > 入参(看板视图给的)> 兜底
const calc: string = s(t.board_color_calc);
const bc: string = calc !== '' ? calc : (boardColor !== '' ? boardColor : '#A8574F');
const it: FbTaskItem = {
id: n(t.id),
title: s(t.title),
status: s(t.status) !== '' ? s(t.status) : 'todo',
board: boardName !== '' ? boardName : s(t.board_name),
boardColor: bc,
dueText: due !== '' ? datePart(due) : '',
overdue: due !== '' && s(t.status) !== 'done' && isBeforeToday(due),
assignee: s(t.assignee_name),
childDone: n(t.child_done),
childTotal: n(t.child_total),
};
return it;
}
/** 账本列表 → FbLedgerBook[](netCent 由 /ledger/<id> 的 summary 补) */
export function parseLedger(json: string): FbLedgerBook[] {
const rows: RawLedgerBook[] = asArray<RawLedgerBook>(json);
const out: FbLedgerBook[] = [];
for (const r of rows) {
const bk: FbLedgerBook = {
id: n(r.id),
name: s(r.name),
netCent: 0, // 概览接口不返回净资产,进详情页时再取 summary
myPerm: s(r.my_perm) !== '' ? s(r.my_perm) : 'read',
entryCount: n(r.entry_count),
};
out.push(bk);
}
return out;
}
/**
* 账本明细 → { 账目, 净资产 }。
*
* ⚠️ 明细要走**两级接口**:`/ledger`(有哪些本)→ `/ledger/<id>/entries`(本里的账目 + 汇总)。
* 净资产(`summary.net`)只在这第二级里有 —— 第一级不返回。
*/
export function parseLedgerEntries(json: string): FbLedgerDetail {
const root: Record<string, Object> | null = asObject(json);
const empty: FbLedgerDetail = { entries: [], netCent: 0, assetCent: 0, debtCent: 0 };
if (root === null) {
return empty;
}
const rows: RawLedgerEntry[] = arr<RawLedgerEntry>(root['items'] as RawLedgerEntry[]);
const sm: RawLedgerSummary = (root['summary'] !== undefined && root['summary'] !== null)
? root['summary'] as RawLedgerSummary : {};
const entries: FbLedgerEntry[] = [];
for (const r of rows) {
entries.push({
id: n(r.id),
name: s(r.title),
kind: s(r.kind),
amountCent: n(r.amount_cents),
// 「挂名的人」优先用昵称(我习惯叫他什么),没有就用本名
ownerName: s(r.person_nick) !== '' ? s(r.person_nick) : s(r.person_name),
updatedAt: datePart(s(r.updated_at)),
});
}
const d: FbLedgerDetail = {
entries: entries,
netCent: n(sm.net),
assetCent: n(sm.asset),
debtCent: n(sm.debt),
};
return d;
}
/**
* 家谱 → FbKinGraph。
*
* ⚠️ 服务端给的关系是**数组**(`parents`/`children`/`spouses`/`siblings`
* 全是 id 数组),端上直接用 —— 不重新推导。
* ⚠️ 亲属称谓(`label`)也**由服务端算**(`Kinship::label`),
* 端上**不重算**:那依赖整个家族图谱,两边算法不一致就会出现
* "App 说堂兄、网页说表兄"。
*/
export function parseKin(json: string): FbKinGraph {
const root: Record<string, Object> | null = asObject(json);
const empty: FbKinGraph = { meId: 0, people: [], total: 0 };
if (root === null) {
return empty;
}
const rows: Record<string, Object>[] =
arr<Record<string, Object>>(root['people'] as Record<string, Object>[]);
const people: FbKinPerson[] = [];
for (const r of rows) {
people.push({
id: n(r['id'] as number),
name: s(r['name'] as string),
shown: s(r['shown'] as string) !== '' ? s(r['shown'] as string) : s(r['name'] as string),
label: s(r['label'] as string),
gen: n(r['gen'] as number),
avatarColor: s(r['avatar_color'] as string),
unit: n(r['unit'] as number),
// ⚠️ 从 `Record<string, Object>` 里取值必须**显式断言**成目标类型 ——
// ArkTS 不会自动把 `Object` 收窄成 `number[]`(编译期报
// "Argument of type 'Object' is not assignable to 'number[]'")。
parents: idList(r['parents'] as number[]),
children: idList(r['children'] as number[]),
spouses: idList(r['spouses'] as number[]),
siblings: idList(r['siblings'] as number[]),
});
}
const g: FbKinGraph = {
meId: n(root['meId'] as number),
people: people,
total: n(root['total'] as number),
};
return g;
}
/** id 数组的容错转换(服务端可能给 null) */
function idList(v: number[] | null | undefined): number[] {
const xs: number[] = arr<number>(v);
const out: number[] = [];
for (const x of xs) {
out.push(n(x));
}
return out;
}
/** 资源 → FbResourceItem[] */
export function parseResources(json: string): FbResourceItem[] {
const rows: RawResource[] = asArray<RawResource>(json);
const out: FbResourceItem[] = [];
for (const r of rows) {
const it: FbResourceItem = {
id: n(r.id),
name: s(r.title),
// 资源没有"类别"字段(服务端的 content 是自由文本),
// ⇒ 用「谁的」来分类更贴近实际用法(Web 端列表也是按归属分组)
kind: 'other',
ownerName: s(r.owner_nick) !== '' ? s(r.owner_nick) : s(r.owner_name),
fileCount: 0,
shared: s(r.perm) !== 'owner',
updatedAt: datePart(s(r.updated_at)),
};
out.push(it);
}
return out;
}
/** 藏书 → FbBookItem[] */
export function parseBooks(json: string): FbBookItem[] {
const rows: RawBook[] = asArray<RawBook>(json);
const out: FbBookItem[] = [];
for (const r of rows) {
const st: string = s(r.status) !== '' ? s(r.status) : 'want';
const it: FbBookItem = {
id: n(r.id),
title: s(r.title),
author: s(r.author),
status: st,
rate: n(r.rating),
noteCount: n(r.note_count),
// 服务端 progress 记的是"读到第几页"还是"百分比"不确定
// ⇒ 只信 status:读完就是 100,否则按 progress 原样(0~100 之外夹紧)
progress: st === 'done' ? 100 : clamp(n(r.progress), 0, 100),
};
out.push(it);
}
return out;
}
/** 笔记 → FbNoteItem[] */
export function parseNotes(json: string): FbNoteItem[] {
const root: Record<string, Object> | null = asObject(json);
if (root === null) {
return [];
}
const rows: RawNote[] = arr<RawNote>(root['items'] as RawNote[]);
const out: FbNoteItem[] = [];
for (const r of rows) {
out.push({
id: n(r.id),
title: s(r.title),
excerpt: firstTextOf(r.blocks),
tags: [],
updatedAt: datePart(s(r.updated_at)),
shared: n(r.is_shared) !== 0,
attachCount: 0,
});
}
return out;
}
/** 通知 → FbNotificationItem[] */
export function parseNotifications(json: string): FbNotificationItem[] {
const root: Record<string, Object> | null = asObject(json);
if (root === null) {
return [];
}
const rows: RawNotification[] = arr<RawNotification>(root['items'] as RawNotification[]);
const out: FbNotificationItem[] = [];
for (const r of rows) {
out.push({
id: n(r.id),
title: s(r.title),
body: s(r.body),
timeText: relativeTime(s(r.created_at)),
unread: s(r.read_at) === '',
from: chip(s(r.actor_name), s(r.avatar_color)),
});
}
return out;
}
/** 首页 → FbHomeData */
/**
* 首页 → FbHomeData(v54:服务端补齐了农历/标记/天气/周统计)。
*
* ⚠️ **不允许**再回落到端上的演示常量 —— 用户 2026-10-16 报过
* 「寒露、重阳节、儿子的生日,这些都是乱写的」,
* 根因就是那些字段接口没给、端上拿 DemoData 顶着。
* 接口没给时应当是**空**(页面就不画那一块),而不是编一个出来。
*/
export function parseHome(json: string): FbHomeData {
const root: Record<string, Object> | null = asObject(json);
const empty: FbHomeData = {
greeting: '', date: '', weekday: '', todayLabel: '', lunarShort: '', lunarLine: '',
marks: [], weather: null,
stat: { todayEvents: 0, doing: 0, todo: 0, overdue: 0 },
memories: [],
todayEvents: [], tasks: [], unread: 0,
};
if (root === null) {
return empty;
}
// ⚠️ ArkTS **不能**从 `unknown[]` 反推泛型(arkts-no-inferred-generic-params)
// ⇒ 一律显式写类型参数,别靠推断。
// ⚠️ 这里直接把原始数组喂给 parseCalendar 的"数组版",
// 比 `JSON.stringify` 再 parse 一次更快、也更不容易出错。
const rawEvents: RawCalEvent[] = arr<RawCalEvent>(root['today_events'] as RawCalEvent[]);
const rawTasks: RawTask[] = arr<RawTask>(root['tasks'] as RawTask[]);
const d: FbHomeData = {
greeting: s(root['greeting'] as string),
date: s(root['date'] as string),
weekday: s(root['weekday'] as string),
todayLabel: s(root['today_label'] as string),
lunarShort: s(root['lunar_short'] as string),
lunarLine: s(root['lunar_line'] as string),
marks: parseMarks(root['marks']),
weather: parseWeather(root['weather']),
stat: parseStat(root['stat']),
memories: parseMemories(root['memories']),
todayEvents: parseCalRows(rawEvents),
tasks: parseTaskRows(rawTasks),
unread: n(root['unread'] as number),
};
return d;
}
/** 日子标记 `[{word, kind}]`(坏行直接丢,不让它污染页面) */
export function parseMarks(v: Object | null | undefined): FbMark[] {
const rows: RawMark[] = arr<RawMark>(v as RawMark[]);
const out: FbMark[] = [];
for (const r of rows) {
const word: string = s(r.word);
if (word === '') {
continue;
}
const mk: FbMark = { word: word, kind: s(r.kind) !== '' ? s(r.kind) : 'anniv' };
out.push(mk);
}
return out;
}
/**
* 天气:服务端没配地点时给 `null` ⇒ 端上**整块不画**(与网页版一致)。
* ⚠️ `temp` 服务端已经带单位(`21°C`),原样承接,端上不再拼。
*/
export function parseWeather(v: Object | null | undefined): FbWeather | null {
if (v === null || v === undefined) {
return null;
}
const o: Record<string, Object> | null = asObjectOf(v);
if (o === null) {
return null;
}
const w: FbWeather = {
place: s(o['place'] as string),
temp: s(o['temp'] as string),
text: s(o['text'] as string),
icon: s(o['icon'] as string),
tone: s(o['tone'] as string) !== '' ? s(o['tone'] as string) : 'cloud',
wind: s(o['wind'] as string),
wdir: s(o['wdir'] as string),
tmax: s(o['tmax'] as string),
tmin: s(o['tmin'] as string),
};
// 温度都没有 ⇒ 当没取到(别画一个空壳)
return w.temp !== '' ? w : null;
}
/** 概述卡底部的四个数字(与网页版 `hb-stats` 同源) */
export function parseStat(v: Object | null | undefined): FbStat {
const o: Record<string, Object> | null = asObjectOf(v);
if (o === null) {
return { todayEvents: 0, doing: 0, todo: 0, overdue: 0 };
}
return {
todayEvents: n(o['today_events'] as number),
doing: n(o['doing'] as number),
todo: n(o['todo'] as number),
overdue: n(o['overdue'] as number),
};
}
/**
* 往年的今天 → `FbMemoryItem[]`(与电脑端 `Home::memories()` 同一份数据)。
*
* ⚠️ 字段是 SQL 风格(`starts_at` / `years_ago`),端上要驼峰 —— 所以有这一层。
*/
export function parseMemories(v: Object | null | undefined): FbMemoryItem[] {
const rows: RawMemory[] = arr<RawMemory>(v as RawMemory[]);
const out: FbMemoryItem[] = [];
for (const r of rows) {
const title: string = s(r.title);
if (title === '') {
continue;
}
const it: FbMemoryItem = {
id: n(r.id),
title: title,
startsAt: s(r.starts_at),
kind: s(r.kind) !== '' ? s(r.kind) : 'movie',
yearsAgo: n(r.years_ago),
};
out.push(it);
}
return out;
}
/** 一批任务行 → FbTaskItem[](首页与任务页共用同一段形状转换) */
export function parseTaskRows(rows: RawTask[]): FbTaskItem[] {
const out: FbTaskItem[] = [];
for (const t of rows) {
out.push(taskRow(t, '', ''));
}
return out;
}
// ============================================================
// 内部工具
// ============================================================
/** 解析成数组(失败给空数组) */
function asArray<T>(json: string): T[] {
if (json === '') {
return [];
}
try {
const v: Object = JSON.parse(json) as Object;
// 服务端信封是 {ok, data};但仓库已经把 data 传出来,这里两种都认
if (Array.isArray(v)) {
return v as T[];
}
const data = (v as Record<string, Object>)['data'];
if (Array.isArray(data)) {
return data as T[];
}
return [];
} catch (e) {
return [];
}
}
/**
* 把一个**已经是对象**的值转成 `Record<string, Object>`(不是对象则 null)。
*
* ⚠️ 与 `asObject()` 的区别:那个吃的是**整份 JSON 字符串**、还要往下钻 `data` 一层;
* 这个吃的是"已经从根对象里取出来的某个字段值"(如 `root['weather']`)。
* ArkTS 里 `as` 是编译期的,运行时 `null`/数组/标量 都能骗过它 ——
* 所以必须自己判一遍类型,别直接 `v as Record<...>`(那样标量上取键会拿到 undefined,
* 然后被当成"字段是空的",症状是"接口明明给了却显示为空")。
*/
function asObjectOf(v: Object | null | undefined): Record<string, Object> | null {
if (v === null || v === undefined) {
return null;
}
if (typeof v !== 'object' || Array.isArray(v)) {
return null;
}
return v as Record<string, Object>;
}
function asObject(json: string): Record<string, Object> | null {
if (json === '') {
return null;
}
try {
const v: Object = JSON.parse(json) as Object;
if (v === null || !Array.isArray(v) && typeof v === 'object') {
const o = v as Record<string, Object>;
const data = o['data'];
if (data !== undefined && data !== null && typeof data === 'object' && !Array.isArray(data)) {
return data as Record<string, Object>;
}
return o;
}
return null;
} catch (e) {
return null;
}
}
/** 两位补零 */
function pad2(v: number): string {
return v < 10 ? '0' + v.toString() : v.toString();
}
/** 夹紧 */
function clamp(v: number, lo: number, hi: number): number {
if (v < lo) {
return lo;
}
if (v > hi) {
return hi;
}
return v;
}
/** 两个时间之间差几天(同一天算 1 天) */
function daySpan(a: string, c: string): number {
const da: string = datePart(a);
const dc: string = datePart(c);
if (da === '' || dc === '') {
return 0;
}
const t1: number = Date.parse(da + 'T00:00:00');
const t2: number = Date.parse(dc + 'T00:00:00');
if (isNaN(t1) || isNaN(t2)) {
return 0;
}
return Math.max(1, Math.round((t2 - t1) / 86400000) + 1);
}
/** 时间范围文案:同一天显示 'M月D日';跨天显示 'M月D日 - M月D日' */
function dateRangeText(a: string, c: string, days: number): string {
const da: string = datePart(a);
const dc: string = datePart(c);
if (da === '') {
return '';
}
const ta: string = cnDate(da);
if (dc === '' || da === dc) {
return ta + (days > 1 ? ' · ' + days.toString() + ' 天' : '');
}
return ta + ' - ' + cnDate(dc) + ' · ' + days.toString() + ' 天';
}
/** 'YYYY-MM-DD' → 'M月D日' */
function cnDate(v: string): string {
if (v.length < 10) {
return v;
}
const m: number = parseInt(v.substring(5, 7), 10);
const d: number = parseInt(v.substring(8, 10), 10);
return m.toString() + '月' + d.toString() + '日';
}
/** 距下一个 (month, day) 还有几天(0 = 今天) */
function daysUntil(today: Date, month: number, day: number): number {
if (month <= 0 || day <= 0) {
return 9999;
}
const y: number = today.getFullYear();
let target: number = Date.UTC(y, month - 1, day);
const base: number = Date.UTC(y, today.getMonth(), today.getDate());
if (target < base) {
target = Date.UTC(y + 1, month - 1, day); // 今年已过 ⇒ 看明年
}
return Math.round((target - base) / 86400000);
}
/** 是否早于今天 */
function isBeforeToday(v: string): boolean {
const d: string = datePart(v);
if (d === '') {
return false;
}
const now: Date = new Date();
const today: string = now.getFullYear().toString() + '-'
+ pad2(now.getMonth() + 1) + '-' + pad2(now.getDate());
return d < today;
}
/** 相对时间文案 */
function relativeTime(v: string): string {
const t: number = Date.parse(s(v).replace(' ', 'T'));
if (isNaN(t)) {
return datePart(s(v));
}
const diff: number = Date.now() - t;
const min: number = Math.floor(diff / 60000);
if (min < 1) {
return '刚刚';
}
if (min < 60) {
return min.toString() + ' 分钟前';
}
const hr: number = Math.floor(min / 60);
if (hr < 24) {
return hr.toString() + ' 小时前';
}
const day: number = Math.floor(hr / 24);
if (day < 30) {
return day.toString() + ' 天前';
}
return datePart(s(v));
}
/** 纪念日类别中文 */
function kindCn(kind: string): string {
if (kind === '生日') {
return 'birthday';
}
if (kind === '节日') {
return 'holiday';
}
if (kind === '节气') {
return 'solar_term';
}
if (kind === '纪念日') {
return 'anniversary';
}
return kind;
}
/** 笔记第一条文本块的正文 */
function firstTextOf(blocks?: RawNoteBlock[]): string {
const bs: RawNoteBlock[] = arr<RawNoteBlock>(blocks);
for (const bl of bs) {
const c: string = s(bl.content).trim();
if (c !== '') {
return c.length > 60 ? c.substring(0, 60) + '…' : c;
}
}
return '';
}
+985
View File
@@ -0,0 +1,985 @@
/**
* 家书 FamilyBook · 名册仓库(鸿蒙端)
*
* 数据流与观影一致:「**接口 → 本地库 → UI**」(本地优先)。
* · `list()` 从**本地库**读(UI 的唯一数据来源,快、断网可看);
* · `sync()` 拉接口 → 落本地库 → 再读一次。
*
* 🔴 本仓库用的是 `GET /api/v1/roster`,**不是** `/api/v1/people`:
* 前者是「**我**名册里的人」(我加的 + 别人分享给我的),
* 后者是"全家的人"(本家 800+,是通讯录底账)。
* Web 端名册页用的也是前者(`Person::roster()`)。
* ⚠️ 用错接口的症状:名册里冒出 800 人,而且全是"别人的通讯录"。
*/
import { relationalStore } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbDb, FbRow } from './FbDb';
import { FbApiOptions, FbApiResult, fbApi } from './FbHttp';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbPersonRepo';
/**
* 每批写入的行数。
*
* ⚠️ 20 是**实测**值(与观影仓库同一个约束):一次塞几百行时
* `batchInsert` 会卡死不返回。别随手改大。
*/
const FB_BATCH_SIZE: number = 20;
/** 名册里一个人(UI 直接渲染的形状) */
export interface FbRosterPerson {
id: number;
/** 本名 */
name: string;
/** 我怎么叫他(昵称优先)—— 卡片主标题用这个 */
shown: string;
/** 我该怎么称呼他(如"父亲""妻子") */
label: string;
/** 标签(一个人可以有多个) */
tags: string[];
avatarColor: string;
avatar: string;
/** 阳历生日 'MM-DD' */
birthdaySolar: string;
/**
* 完整生日 `YYYY-MM-DD`(空 = 没录)。
*
* 🔴 为什么要**两个**生日字段:
* · `birthdaySolar` = `MM-DD`,给**列表/详情显示**用(不显示年份,避免"暴露年龄");
* · `birthdayFull` = `YYYY-MM-DD`,给**编辑**与**入库**用。
* ⚠️ 之前只存了 `MM-DD`,**把年份弄丢了** ——
* 结果"编辑生日"根本没法回填(输入框要 `YYYY-MM-DD`)。
* 这是一个真实的取整错误,不是设计取舍。
*/
birthdayFull: string;
/** 阴历生日(如"三月初七") */
birthdayLunar: string;
/** 手机号明文(服务端解密后给的;空 = 没录)。= `phones[0]` */
phone: string;
/**
* **全部电话**(主号在前,`person_contacts` 里的补充电话在后)。
*
* 🔴 为什么需要(用户 2026-10-15):
* 「一个人可能有多个电话号码……应该拨打最近的那个电话。」
* 实测 **161 人有多个号码**(如"杨永华"有 4 个:手机 + 座机 + 两个副号)
* ⇒ 只带主号的话,用户想打座机就没法从应用里发起拨号。
* ⚠️ 服务端保证「主号排第一」⇒ 端上"有主号打主号"就是取 `phones[0]`。
* ⚠️ 空数组表示这人一个号码都没录 ⇒ 点行时走"打开详情"而不是拨号。
*/
phones: string[];
/** 首字母("输入即筛选"用) */
initials: string;
org: string;
title: string;
slogan: string;
email: string;
addrCity: string;
gender: string;
note: string;
/** 是管理员(显示徽标) */
isAdmin: boolean;
// ============================================================
// 🔴 v52(用户 2026-10-15):**全字段**。
//
// 「我们现在这个应用打造的目的就是为了让用户可以完全脱离电脑端……
// 应该把所有的信息都显示出来,而且编辑的时候,所有的内容都应该可以编辑。」
//
// ⇒ 下面这些是**详情/编辑**才用得上的字段。
// 列表页不显示它们,但它们必须能取到 —— 否则"编辑"时回填不了、
// "详情"时看不到,就还是得回电脑端。
// ============================================================
/** 结构化地址:国家 / 省 / 市 / 区(详细街道在 `address`) */
addrCountry: string;
addrProvince: string;
addrDistrict: string;
/** 详细地址(街道门牌) */
address: string;
/** 家中排行(1 = 老大;空 = 没填) */
birthRank: string;
/** 农历生日的**闰月**标记 */
birthdayLunarLeap: boolean;
/**
* 农历生日的**完整日期** `YYYY-MM-DD`(空 = 没录)。
*
* 🔴 与 `birthdayLunar`("十月初五"这种**给人看的**月日)分开:
* · `birthdayLunar` —— 显示用(服务端算好的中文月日)
* · `birthdayLunarRaw` —— **编辑**用(要能回填进 `YYYY-MM-DD` 输入框)
* ⚠️ 只存中文月日就没法回填("十月初五"不是日期),
* 这正是"农历生日改不了"的原因。服务端在详情里给了完整值。
*/
birthdayLunarRaw: string;
/** 补充联系方式(电话/邮箱/地址/网址,各可多行) */
contacts: FbContacts;
/** 工作经历(可多段) */
jobs: FbJobRow[];
/**
* 「**我对他的称呼**」(只有我看得到;空 = 用姓名显示)。
*
* 🔴 用户 2026-10-15:「编辑信息的时候,应该有我对他的称呼才对呀。」
* 称呼是**视角属性**:妈妈叫"明明"、姐姐叫"小弟",各人不同
* ⇒ 存在服务端 `nicknames` 表(viewer × target),**不挂在被称呼者身上**。
* ⚠️ 与 `shown` 的区别:`shown` 是"显示名"(有称呼用称呼、没有用姓名),
* 编辑框要回填**原始称呼** ⇒ 反推不出来,必须有这个字段。
*/
myNickname: string;
/**
* 「我写的私人备注」。
*
* 🔴 与公共 `note` **不是一回事**:
* `note` = 大家的公共备注(谁都能看、谁都能改)
* `myNote`= **只有我**看得到的备注(我对这个人的印象/提醒)
* 两者在详情里**分两块显示**,别混成一条。
*/
myNote: string;
/** 身份证(**打码**过的,如 `372****4856`;编辑时重新输入才改) */
idCardMasked: string;
/** 当前登录者能不能改这人的全部字段(对齐服务端 `can_edit_all`) */
canEditAll: boolean;
/** 这人是不是"我" */
isSelf: boolean;
}
/** 名册同步结果 */
export interface FbRosterSyncResult {
ok: boolean;
saved: number;
error: string;
}
/** 一条补充联系方式(vCard 对齐:label 是"工作/家庭"这种说明,可空) */
export interface FbContactRow {
label: string;
value: string;
}
/** 补充联系方式(按类型分组) */
export interface FbContacts {
phone: FbContactRow[];
email: FbContactRow[];
address: FbContactRow[];
url: FbContactRow[];
}
/** 一段工作经历 */
export interface FbJobRow {
org: string;
title: string;
/** 职称(如 副教授) */
rankName: string;
/** 起始年月 `YYYY-MM`(空 = 没填) */
startYm: string;
/** 结束年月(**空 = 现在还在**,列表里显示为「现在」) */
endYm: string;
note: string;
}
/** 名册整体(人 + 标签云 + 我的 id) */
export interface FbRosterBundle {
people: FbRosterPerson[];
/** 标签云:[{tag, n}] */
tagCloud: FbTagCount[];
meId: number;
}
/** 标签云的一项 */
export interface FbTagCount {
tag: string;
n: number;
}
// ============================================================
// 接口原始形状(**按真实响应写**,字段名以实测为准)
// ============================================================
interface FbApiRosterPerson {
id?: number;
name?: string;
shown?: string;
label?: string;
tags?: string[];
avatar_color?: string | null;
avatar?: string | null;
/** 服务端给的是 `[['阳历','03-30'], ['农历','三月初七']]` 这种二元组数组 */
/** 完整生日 `YYYY-MM-DD HH:mm:ss`(服务端给年份;编辑要用) */
birthday?: string | null;
birthdays?: string[][];
phone_plain?: string | null;
/** 🔴 **全部电话**(主号在前 + 补充电话)。App 点人名拨号用 */
phones?: string[] | null;
initials?: string | null;
org?: string | null;
title?: string | null;
slogan?: string | null;
email?: string | null;
addr_city?: string | null;
gender?: string | null;
note?: string | null;
account_role?: string | null;
account_status?: string | null;
// ---- v52:单人详情接口(`GET /api/v1/people/<id>`)才有的字段 ----
/** 结构化地址(详情接口给;列表只给到 `addr_city`) */
addr_country?: string | null;
addr_province?: string | null;
addr_district?: string | null;
address?: string | null;
birth_rank?: number | string | null;
is_placeholder?: number | null;
/** 农历生日完整日期 `YYYY-MM-DD`(详情接口给;编辑要用) */
birthday_lunar?: string | null;
/** 补充联系方式,按类型分组;电话已解密 */
contacts?: Record<string, FbApiContactRaw[]> | null;
/** 工作经历(服务端列名是下划线风格) */
jobs?: FbApiJobRaw[] | null;
/** 「我写的私人备注」 */
my_note?: string | null;
/** 「我对他的称呼」(视角属性;空 = 用姓名显示) */
my_nickname?: string | null;
/** 身份证(打码) */
id_card_masked?: string | null;
/** 能不能改全部字段 */
can_edit_all?: boolean | null;
is_self?: boolean | null;
}
/** 详情接口里的一条补充联系方式 */
interface FbApiContactRaw {
label?: string | null;
value?: string | null;
}
/** 详情接口里的一段工作经历(列名是下划线) */
interface FbApiJobRaw {
org?: string | null;
title?: string | null;
rank_name?: string | null;
start_ym?: string | null;
end_ym?: string | null;
note?: string | null;
}
interface FbApiRosterPayload {
people?: FbApiRosterPerson[];
tagCloud?: FbApiTagItem[];
meId?: number;
}
interface FbApiTagItem {
tag?: string;
n?: number;
}
// ============================================================
export class FbPersonRepo {
private static _inst: FbPersonRepo | null = null;
private db: FbDb = FbDb.inst();
/** 单例(与服务端 `Person` 一样是纯静态工具,端上用单例便于将来加缓存) */
static inst(): FbPersonRepo {
if (FbPersonRepo._inst === null) {
FbPersonRepo._inst = new FbPersonRepo();
}
return FbPersonRepo._inst;
}
/**
* 拉名册接口 → 落本地库 → 返回同步结果。
*
* ⚠️ 与观影仓库同样的两条约束(都是实测踩出来的):
* ① **不用显式事务**(本机设备上 `createTransaction` / 老事务都卡死);
* ② `batchInsert` **分小批**(每批 20 行),一次塞几百行会卡死。
* 换取的是"幂等 + 全量重拉",对本场景足够。
*/
async sync(token: string): Promise<FbRosterSyncResult> {
const opts: FbApiOptions = { method: 'GET', token: token };
const res: FbApiResult = await fbApi('/api/v1/roster', opts);
if (!res.ok) {
const bad: FbRosterSyncResult = { ok: false, saved: 0, error: res.error };
return bad;
}
const parsed: FbParsedRoster = parseRoster(res.data);
const meId: number = parsed.meId;
const now: number = Date.now();
// ---- 人员(persons 表;只补名册用到的字段)----
const personRows: relationalStore.ValuesBucket[] = [];
for (const p of parsed.people) {
const b: relationalStore.ValuesBucket = {};
b['id'] = p.id;
// ⚠️ family_id 接口不返回(名册行里没有)—— 本 App 只服务一个家庭,
// 填 0 不影响;真要取值应另拉 `/me`。这里**不猜**。
b['family_id'] = 0;
b['kind'] = 'member';
b['name'] = p.name;
b['nickname'] = p.shown !== p.name ? p.shown : null;
b['gender'] = p.gender;
// ⚠️ 存**完整**生日(`birthdayFull`),不是只有月日的 `birthdaySolar`
b['birthday'] = p.birthdayFull;
b['avatar'] = p.avatar;
b['note'] = p.note;
b['avatar_color'] = p.avatarColor;
b['email'] = p.email;
b['org'] = p.org;
b['title'] = p.title;
b['slogan'] = p.slogan;
b['address'] = p.addrCity; // 名册只给到城市
b['addr_city'] = p.addrCity;
b['updated_at'] = '';
b['synced_at'] = now;
b['dirty'] = 0;
personRows.push(b);
}
// ---- 名册关系(person_roster)----
const rosterRows: relationalStore.ValuesBucket[] = [];
for (let i = 0; i < parsed.people.length; i++) {
const p: FbRosterPerson = parsed.people[i];
const b: relationalStore.ValuesBucket = {};
b['owner_id'] = meId;
b['person_id'] = p.id;
b['family_id'] = 0;
b['group_id'] = 0;
// 顺序就用接口给的顺序(服务端 ORDER BY sort_order),保住用户的拖动排序
b['sort_order'] = i;
b['source'] = 'self';
b['updated_at'] = '';
b['synced_at'] = now;
// 🔴 全部电话(JSON 数组,主号在前)。见 FbRosterPerson.phones 的说明。
// ⚠️ 存 JSON 而不是建表:电话不做跨人聚合,整组读出来就够。
b['phones'] = JSON.stringify(p.phones);
rosterRows.push(b);
}
// ---- 标签(person_tags)----
const tagRows: relationalStore.ValuesBucket[] = [];
for (const p of parsed.people) {
for (const t of p.tags) {
const b: relationalStore.ValuesBucket = {};
b['owner_id'] = meId;
b['person_id'] = p.id;
b['tag'] = t;
b['updated_at'] = '';
b['synced_at'] = now;
tagRows.push(b);
}
}
const store: relationalStore.RdbStore = this.db.require();
// 🔴 顺序:先 persons(被引用者),再 roster / tags。
// 🔴🔴 **必须用 REPLACE 语义**(用户 2026-10-15 实测踩出来的真 bug):
// `persons` 是**跨模块共用**的表(名册、观影的"同看人"都写它),
// 它的行**不能"先清空再插"**(会连带删掉观影那边引用的人),
// 所以只能靠主键冲突来解决 —— 而普通的 `batchInsert` 在 PK 冲突时
// 是 **ON_CONFLICT_IGNORE 语义(什么都不做)**。
// 后果:**已存在的人永远不会被更新** ⇒
// 服务端后来补的 `email / 座右铭 / 地址 / 单位` 这些字段
// 在端上**永远是旧的(空)** —— 实测"杨永全"的详情浮层里
// "座右铭 / 邮箱 / 所在地"三行直接不显示(服务端明明有值)。
// ⇒ 用 `batchInsertWithConflictResolution(..., ON_CONFLICT_REPLACE)`。
// ⚠️ 该 API `@since 18`,本工程 compatibleSdkVersion 24,可用。
await this.insertInBatches(store, 'persons', personRows, true);
await this.insertInBatches(store, 'person_roster', rosterRows);
// ⚠️ 标签是**全量重拉**:先清掉我这个 owner 的旧标签再写新的。
// 否则"删掉一个标签"在本地永远消不掉(旧行还在)。
await store.executeSql('DELETE FROM person_tags WHERE owner_id = ?', [meId]);
await this.insertInBatches(store, 'person_tags', tagRows);
// ⚠️ 名册关系同样全量替换(服务端返回的就是"我名册的全部")
await store.executeSql('DELETE FROM person_roster WHERE owner_id = ?', [meId]);
await this.insertInBatches(store, 'person_roster', rosterRows);
// 🔴 **标签云以服务端下发的为准**(用户 2026-10-15 报"标签只有全部")。
//
// 为什么不能只靠本地 `GROUP BY person_tags` 聚合:
// · 它只能在**标签行都写成功**时才对;一旦写入环节有任何遗漏
// (批量插入失败、字段为空…),标签条就残了 —— 而用户看到的是
// "标签条只剩一个『全部』",根本无从判断是哪一环坏的;
// · 服务端 `/roster` **本来就返回了权威的 `tagCloud`**
// (实测 15 个:大学同学 55 / 同事 40 / 实验室 23 …),
// 且与网页端同源 ⇒ 用它才能保证"App 与网页的标签条完全一致"。
// ⚠️ 缓存键用 `roster_tagcloud`(存进通用的 `module_cache`,
// 不必新建表;见 FbModuleRepo 的说明)。
await this.saveTagCloud(parsed.tagCloud);
const done: FbRosterSyncResult = {
ok: true, saved: parsed.people.length, error: ''
};
return done;
}
/**
* 把服务端下发的标签云存进 `module_cache`。
*
* ⚠️ 存的是**接口原样的 JSON**,读取时再解析 —— 与其它模块缓存同一套做法,
* 这样"标签云"将来加字段(如颜色)不用改表结构。
*/
private async saveTagCloud(list: FbTagCount[]): Promise<void> {
try {
const payload: string = JSON.stringify(list);
await this.db.require().executeSql(
'INSERT OR REPLACE INTO module_cache (name, payload, fetched_at) VALUES (?, ?, ?)',
['roster_tagcloud', payload, Date.now()]);
} catch (err) {
// 存缓存失败**不该**让整次同步失败(名册本体已经写好了)。
hilog.warn(DOMAIN, TAG, '标签云缓存失败:%{public}s', JSON.stringify(err));
}
}
/**
* 读标签云。
*
* ⚠️ **优先用服务端下发的**(`module_cache` 里的 `roster_tagcloud`);
* 读不到(首次安装、还没同步过)才**回落到本地聚合**。
* ⇒ 这样"已同步过"的设备一定与网页一致,而"没同步过"的设备
* 至少还能从已有的 `person_tags` 里凑出一份。
*/
async tagCloud(): Promise<FbTagCount[]> {
// ① 服务端下发的(权威)
try {
const rows: FbRow[] = await this.db.queryRows(
"SELECT payload FROM module_cache WHERE name = 'roster_tagcloud'", []);
for (const r of rows) {
const raw: string = r.strOr('payload', '');
if (raw !== '') {
const list: FbTagCount[] = JSON.parse(raw) as FbTagCount[];
if (Array.isArray(list) && list.length > 0) {
return list;
}
}
}
} catch (err) {
hilog.warn(DOMAIN, TAG, '读标签云缓存失败,回落到本地聚合:%{public}s',
JSON.stringify(err));
}
// ② 回落:本地 `person_tags` 聚合
return this.tagCloudLocal();
}
/**
* 本地聚合(回落用)。
*
* ⚠️ 排序与服务端 `PersonTag::cloud()` 对齐:**按人数降序、同数按标签名升序**。
* 两边顺序不一致的话,用户会看到"网页和 App 的标签条顺序不一样"。
*/
private async tagCloudLocal(): Promise<FbTagCount[]> {
const rows: FbRow[] = await this.db.queryRows(
'SELECT tag, COUNT(*) AS n FROM person_tags GROUP BY tag ORDER BY n DESC, tag ASC');
const out: FbTagCount[] = [];
for (const r of rows) {
const tag: string = r.strOr('tag', '');
if (tag !== '') {
const c: FbTagCount = { tag: tag, n: r.num('n', 0) };
out.push(c);
}
}
return out;
}
/**
* 从本地库读名册(**UI 的唯一数据来源**)。
*
* ⚠️ 标签用 `GROUP_CONCAT` 在 SQL 里拼成一列(再在解析时拆开),
* 而不是"每人再查一次标签表" —— 800 人会变成 800 次查询,
* 手机上是实打实的卡顿。
*/
async list(keyword: string = ''): Promise<FbRosterPerson[]> {
const sql: string =
'SELECT p.id, p.name, p.nickname, p.gender, p.birthday, p.avatar, p.avatar_color,' +
' p.note, p.email, p.org, p.title, p.slogan, p.addr_city,' +
' r.sort_order, r.phones,' +
' (SELECT GROUP_CONCAT(t.tag, "\\u0001") FROM person_tags t' +
' WHERE t.person_id = p.id AND t.owner_id = r.owner_id) AS tags' +
' FROM person_roster r JOIN persons p ON p.id = r.person_id' +
' ORDER BY r.sort_order ASC, p.id ASC';
const rows: FbRow[] = await this.db.queryRows(sql, []);
const out: FbRosterPerson[] = [];
for (const r of rows) {
const p: FbRosterPerson = this.rowToPerson(r);
if (keyword !== '' && !matches(p, keyword)) {
continue;
}
out.push(p);
}
return out;
}
/**
* 读名册的元信息(人 + 标签云 + 我的 id)。
*
* ⚠️ 标签云**从本地库当场聚合**(`GROUP BY tag`),不从接口另存一份:
* 数据本来就在 `person_tags` 里,存两份迟早不一致。
*/
async meta(): Promise<FbRosterBundle> {
const people: FbRosterPerson[] = await this.list();
const tagCloud: FbTagCount[] = await this.tagCloud();
// 「我的 id」从名册关系里取(owner_id 就是我自己)
const meId: number = await this.db.scalar(
'SELECT IFNULL(MAX(owner_id), 0) FROM person_roster');
const bundle: FbRosterBundle = { people: people, tagCloud: tagCloud, meId: meId };
return bundle;
}
/** 本地库里名册人数(自检/显示用) */
async count(): Promise<number> {
return this.db.scalar('SELECT COUNT(*) FROM person_roster');
}
/**
* 拉**单人完整详情**(`GET /api/v1/people/<id>`)。
*
* 🔴 为什么详情不走本地库(v52,用户 2026-10-15):
* 本地库的 `persons` 表是为**列表**设计的(只存画一栏要用的字段),
* 而"全功能"要求详情能看到**工作经历、补充电话、私人备注**这些
* 一对多 / 只有我看得到的数据 —— 把它们全搬进本地库要再加两张表 +
* 一套同步,收益只是"省一次请求"。
* ⇒ 详情**直接联网取**(一次往返、几百毫秒),取到就画。
* ⚠️ 断网时返回 `null`,调用方要能退化成"用列表里那份画"(见调用处)。
*/
async fetchDetail(id: number, token: string): Promise<FbRosterPerson | null> {
if (id <= 0) {
return null;
}
const res: FbApiResult = await fbApi('/api/v1/people/' + id.toString(), { token: token });
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '详情拉取失败(%{public}d):%{public}s', id, res.error);
return null;
}
try {
const o: FbApiRosterPerson = JSON.parse(res.data) as FbApiRosterPerson;
return personFromApi(o);
} catch (err) {
hilog.error(DOMAIN, TAG, '详情解析失败:%{public}s', JSON.stringify(err));
return null;
}
}
/**
* 保存一个人(`PATCH /api/v1/people/<id>`),全字段。
*
* @param fields 要改的字段(**只传变化的**;服务端对没传的字段沿用旧值,
* 所以不会出现"改一项、其余被清空")。
* @returns 成功时返回服务端回写的**完整详情**(拿它刷新界面最准)
*/
async save(id: number, fields: Record<string, Object>, token: string): Promise<FbApiResult> {
const res: FbApiResult = await fbApi('/api/v1/people/' + id.toString(),
{ method: 'PATCH', body: fields, token: token });
return res;
}
/** 新建一个人(`POST /api/v1/people`),全字段 */
async create(fields: Record<string, Object>, token: string): Promise<FbApiResult> {
const res: FbApiResult = await fbApi('/api/v1/people',
{ method: 'POST', body: fields, token: token });
return res;
}
/** 删除一个人(`DELETE /api/v1/people/<id>`) */
async remove(id: number, token: string): Promise<FbApiResult> {
return await fbApi('/api/v1/people/' + id.toString(), { method: 'DELETE', token: token });
}
/** 一行的解析(不做标签/生日的额外查询) */
private rowToPerson(r: FbRow): FbRosterPerson {
const name: string = r.strOr('name', '');
// 电话(JSON 数组;列可能为空串/非法 JSON ⇒ 容错成空数组)
const phonesArr: string[] = [];
const phonesRaw: string = r.strOr('phones', '');
if (phonesRaw !== '') {
try {
const parsedArr: string[] = JSON.parse(phonesRaw) as string[];
if (Array.isArray(parsedArr)) {
for (const ph of parsedArr) {
if (typeof ph === 'string' && ph !== '') {
phonesArr.push(ph);
}
}
}
} catch (err) {
hilog.warn(DOMAIN, TAG, 'phones JSON 解析失败:%{public}s', JSON.stringify(err));
}
}
const nick: string = r.strOr('nickname', '');
const tagsRaw: string = r.strOr('tags', '');
const tags: string[] = [];
if (tagsRaw !== '') {
for (const t of tagsRaw.split('\u0001')) {
if (t !== '') {
tags.push(t);
}
}
}
const birthday: string = r.strOr('birthday', '');
const p: FbRosterPerson = {
id: r.num('id', 0),
name: name,
shown: nick !== '' ? nick : name,
// ⚠️ 称谓("父亲""妻子")由服务端算好下发,**本地库不存** ——
// 它依赖整个家族图谱(Kinship),端上重算既费时又容易算错。
// ⇒ 这里只能从名字/昵称兜底;要显示准确称谓需 `listWithLabel()`(拉接口)。
label: '',
tags: tags,
avatarColor: r.strOr('avatar_color', ''),
avatar: r.strOr('avatar', ''),
birthdaySolar: birthday.length >= 10 ? birthday.substring(5, 10) : '',
birthdayFull: birthday.length >= 10 ? birthday.substring(0, 10) : '',
birthdayLunar: '',
// 🔴 电话从 `person_roster.phones`(JSON 数组)读。
// ⚠️ 之前这里是**空串** —— 结果是"列表里点人拨号"根本没号码可拨
// (只有走接口的 `listWithLabel()` 才带 phone)。这是个真 bug。
phone: phonesArr.length > 0 ? phonesArr[0] : '',
phones: phonesArr,
initials: '',
org: r.strOr('org', ''),
title: r.strOr('title', ''),
slogan: r.strOr('slogan', ''),
email: r.strOr('email', ''),
addrCity: r.strOr('addr_city', ''),
gender: r.strOr('gender', ''),
note: r.strOr('note', ''),
isAdmin: false,
// ---- v52:本地库**不存**这些(详情接口才有)----
// ⚠️ 本地库是为**列表**设计的(只存画一栏要用的字段)。
// 工作经历/补充电话/私人备注/各级地址是"详情专用",
// 它们在服务端而是**一对多**(工作经历、补充电话)或**按查看者不同**
// (私人备注、标签),搬进本地库要再加两张表 + 一套同步 ——
// 为省一次请求不值得。
// ⇒ 详情**联网取**(见 `fetchDetail`),断网时这几项为空、其余照常显示。
addrCountry: '',
addrProvince: '',
addrDistrict: '',
address: r.strOr('address', ''),
birthRank: '',
birthdayLunarLeap: false,
birthdayLunarRaw: '',
contacts: { phone: [], email: [], address: [], url: [] },
jobs: [],
myNote: '',
myNickname: '',
idCardMasked: '',
canEditAll: false,
isSelf: false
};
return p;
}
/** 分批批量插入(理由见 FB_BATCH_SIZE 的说明) */
/**
* 批量写入(分片防单条 SQL 过长)。
*
* @param replace 主键冲突时**覆盖**还是**忽略**。
* ⚠️ `persons` 必须传 `true`(跨模块共用表,不能先清空,只能靠 REPLACE 更新)。
* 为什么(用户 2026-10-15 实测的真 bug):
* 默认 `batchInsert` 在 PK 冲突时是 **IGNORE 语义**(什么都不做)⇒
* **已存在的人永远不更新** ⇒ 服务端后来补的 email / 座右铭 / 地址
* 在端上永远为空("杨永全"的详情浮层里那三行干脆不显示)。
* ⚠️ `person_roster` 传 `false` 即可 —— 它在写入前已经全量 DELETE,
* 不存在冲突。(两个调用点的 linter 也会提示重复写入,见下。)
*/
private async insertInBatches(store: relationalStore.RdbStore, table: string,
rows: relationalStore.ValuesBucket[], replace: boolean = false): Promise<void> {
if (rows.length === 0) {
return;
}
for (let i = 0; i < rows.length; i += FB_BATCH_SIZE) {
const chunk: relationalStore.ValuesBucket[] = rows.slice(i, i + FB_BATCH_SIZE);
if (replace) {
await store.batchInsertWithConflictResolution(
table, chunk, relationalStore.ConflictResolution.ON_CONFLICT_REPLACE);
} else {
await store.batchInsert(table, chunk);
}
}
}
}
// ============================================================
// 解析
// ============================================================
interface FbParsedRoster {
people: FbRosterPerson[];
tagCloud: FbTagCount[];
meId: number;
}
/** 安全取字符串 */
function strOf(v: string | null | undefined): string {
return v !== null && v !== undefined ? v : '';
}
/** 安全取数字 */
function numOf(v: number | null | undefined): number {
return v !== null && v !== undefined ? v : 0;
}
/**
* 数字 → 字符串(用于排行这种"可能是 number 也可能是 string"的字段)。
* ⚠️ 0 视为"没填" ⇒ 返回空串(服务端 NULL 会变 0,别在界面上显示一个 0)。
*/
function numToStr(v: number | string | null | undefined): string {
if (v === null || v === undefined) {
return '';
}
if (typeof v === 'number') {
return v > 0 ? v.toString() : '';
}
return v;
}
/** 归一化补充联系方式(按类型分组,丢掉空值行) */
function normaliseContacts(raw?: Record<string, FbApiContactRaw[]> | null): FbContacts {
const out: FbContacts = { phone: [], email: [], address: [], url: [] };
if (raw === null || raw === undefined) {
return out;
}
const kinds: string[] = ['phone', 'email', 'address', 'url'];
for (const kind of kinds) {
const rows: FbApiContactRaw[] = raw[kind] !== undefined && raw[kind] !== null ? raw[kind] : [];
const list: FbContactRow[] = [];
for (const r of rows) {
const val: string = strOf(r.value);
if (val !== '') {
const row: FbContactRow = { label: strOf(r.label), value: val };
list.push(row);
}
}
if (kind === 'phone') {
out.phone = list;
} else if (kind === 'email') {
out.email = list;
} else if (kind === 'address') {
out.address = list;
} else {
out.url = list;
}
}
return out;
}
/** 归一化工作经历 */
function normaliseJobs(raw?: FbApiJobRaw[] | null): FbJobRow[] {
const out: FbJobRow[] = [];
if (raw === null || raw === undefined) {
return out;
}
for (const r of raw) {
const row: FbJobRow = {
org: strOf(r.org),
title: strOf(r.title),
rankName: strOf(r.rank_name),
startYm: strOf(r.start_ym),
endYm: strOf(r.end_ym),
note: strOf(r.note)
};
// 全空的行不显示(服务端可能留了占位行)
if (row.org !== '' || row.title !== '' || row.rankName !== '') {
out.push(row);
}
}
return out;
}
/**
* 关键词匹配(**本地筛选**,与服务端 `PersonQuery` 的口径尽量贴近)。
*
* ⚠️ 本地能匹配的只有"库里有明文的东西":姓名 / 昵称 / 手机号 / 标签 /
* 单位职务 / 首字母。
* ⚠️ **称谓("父亲")匹配不到** —— 那个值依赖家族图谱、端上没算。
* 这是**有意的取舍**:本地搜索快、断网可用;要按称谓搜就调接口(`?q=`)。
*/
function matches(p: FbRosterPerson, q: string): boolean {
const k: string = q.trim().toLowerCase();
if (k === '') {
return true;
}
const hay: string[] = [
p.name.toLowerCase(), p.shown.toLowerCase(), p.phone,
p.org.toLowerCase(), p.title.toLowerCase(), p.initials.toLowerCase(),
];
for (const t of p.tags) {
hay.push(t.toLowerCase());
}
for (const h of hay) {
if (h !== '' && h.indexOf(k) >= 0) {
return true;
}
}
return false;
}
/** 把接口的标签云规整成端上的形状 */
/**
* 归一化"全部电话"。
*
* ⚠️ 优先用服务端的 `phones`(**它保证主号在前**);
* 没有(老接口)就回落成 `[phone_plain]`。
* ⚠️ 去重 + 去空:服务端理论上已经做过,但端上再兜一层(数据脏了不至于重复显示)。
*/
function normalisePhones(o: FbApiRosterPerson): string[] {
const out: string[] = [];
const arr: string[] | null | undefined = o.phones;
if (arr !== null && arr !== undefined && Array.isArray(arr)) {
for (const ph of arr) {
if (typeof ph === 'string' && ph !== '' && out.indexOf(ph) < 0) {
out.push(ph);
}
}
}
if (out.length === 0) {
const main: string = strOf(o.phone_plain);
if (main !== '') {
out.push(main);
}
}
return out;
}
function normaliseTagCloud(list?: FbApiTagItem[]): FbTagCount[] {
const out: FbTagCount[] = [];
if (list === undefined || !Array.isArray(list)) {
return out;
}
for (const it of list) {
const tag: string = strOf(it.tag);
if (tag !== '') {
const c: FbTagCount = { tag: tag, n: numOf(it.n) };
out.push(c);
}
}
return out;
}
/**
* 把**接口给的一份人员数据**映射成端上的 `FbRosterPerson`。
*
* 🔴 抽成函数的原因(v52):列表(`/roster` 的 people[])与
* 详情(`GET /people/<id>`)**形状几乎一样**,只是详情多带几个字段。
* ⇒ 共用同一套映射,字段一旦新增/改名只改一处 ——
* 两份映射必然漂移(本项目已经在"两个 tagCloud 分支"上踩过一次)。
* ⚠️ 列表接口不带 `contacts`/`jobs`/`my_note` 等字段时,这里会缺省成空,
* 由详情接口补齐;这不影响列表渲染。
*/
function personFromApi(o: FbApiRosterPerson): FbRosterPerson {
const id: number = numOf(o.id);
// 生日:从二元组数组里挑"阳历 / 农历"
let solar: string = '';
let lunar: string = '';
const bdays: string[][] = o.birthdays !== undefined ? o.birthdays : [];
for (const pair of bdays) {
if (pair.length >= 2) {
const kind: string = strOf(pair[0]);
const val: string = strOf(pair[1]);
if (kind === '阳历') {
solar = val;
} else if (kind === '农历') {
lunar = val;
}
}
}
const name: string = strOf(o.name);
const shown: string = strOf(o.shown) !== '' ? strOf(o.shown) : name;
const tags: string[] = [];
if (o.tags !== undefined && Array.isArray(o.tags)) {
for (const t of o.tags) {
if (typeof t === 'string' && t !== '') {
tags.push(t);
}
}
}
const phones: string[] = normalisePhones(o);
const p: FbRosterPerson = {
id: id,
name: name,
shown: shown,
label: strOf(o.label),
tags: tags,
avatarColor: strOf(o.avatar_color),
avatar: strOf(o.avatar),
birthdaySolar: solar,
// 🔴 完整生日取自**接口的 `birthday`**(`1955-03-30 00:00:00`),
// 而不是 `birthdays` 数组 —— 后者**只有月日**(服务端刻意不显示年份)。
// ⚠️ 编辑生日要完整日期 ⇒ 必须有这个字段。
birthdayFull: strOf(o.birthday).length >= 10 ? strOf(o.birthday).substring(0, 10) : '',
birthdayLunar: lunar,
// 🔴 全部电话:优先用服务端下发的 `phones`(主号在前);
// 老版本接口没这个字段时,回落到 `phone_plain`(至少主号能打)。
phones: phones,
phone: phones.length > 0 ? phones[0] : '',
initials: strOf(o.initials),
org: strOf(o.org),
title: strOf(o.title),
slogan: strOf(o.slogan),
email: strOf(o.email),
addrCity: strOf(o.addr_city),
gender: strOf(o.gender),
note: strOf(o.note),
// 管理员徽标:服务端给的是 account_role / account_status
isAdmin: strOf(o.account_role) === 'admin' && strOf(o.account_status) === 'active',
// ---- v52 全字段(列表接口一般不带,缺省成空;详情接口会补齐)----
addrCountry: strOf(o.addr_country),
addrProvince: strOf(o.addr_province),
addrDistrict: strOf(o.addr_district),
address: strOf(o.address),
birthRank: numToStr(o.birth_rank),
birthdayLunarLeap: false,
birthdayLunarRaw: strOf(o.birthday_lunar),
contacts: normaliseContacts(o.contacts),
jobs: normaliseJobs(o.jobs),
myNote: strOf(o.my_note),
myNickname: strOf(o.my_nickname),
idCardMasked: strOf(o.id_card_masked),
canEditAll: o.can_edit_all === true,
isSelf: o.is_self === true
};
return p;
}
/**
* 解析名册响应。
*
* ⚠️ 生日的形状是**实测**来的:服务端 `PersonQuery::birthdaysOf()` 返回
* `[['阳历','03-30'], ['农历','三月初七']]` —— 二元组数组,
* 不是 `{solar,lunar}` 对象。写解析前**先打接口看真实形状**,别猜。
*/
function parseRoster(json: string): FbParsedRoster {
const empty: FbParsedRoster = { people: [], tagCloud: [], meId: 0 };
if (json === '') {
return empty;
}
let payload: FbApiRosterPayload;
try {
payload = JSON.parse(json) as FbApiRosterPayload;
} catch (err) {
hilog.error(DOMAIN, TAG, '名册解析失败:%{public}s', JSON.stringify(err));
return empty;
}
if (payload === null || payload === undefined) {
return empty;
}
const people: FbRosterPerson[] = [];
const raw: FbApiRosterPerson[] = payload.people !== undefined ? payload.people : [];
for (const o of raw) {
const p: FbRosterPerson = personFromApi(o);
if (p.id > 0) {
people.push(p);
}
}
const parsed: FbParsedRoster = {
people: people,
tagCloud: normaliseTagCloud(payload.tagCloud),
meId: numOf(payload.meId)
};
return parsed;
}
+297
View File
@@ -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
};
+179
View File
@@ -0,0 +1,179 @@
/**
* 家书 FamilyBook · 服务器地址存储(鸿蒙端)
*
* 只存一件事:**这台手机连的是哪台服务器**(形如 `https://family.example.com`)。
*
* 🔴 为什么它必须存在(用户 2026-10-17「应用第一次打开,让用户输入服务器地址」):
* 在此之前地址是 `FbHttp.ets` 里的一个**编译期常量** —— 意味着这个 App
* 只能连我们自己那一台,别人装上去就是"登录永远失败",且用户看不到任何原因
* (他甚至不知道"地址"这个东西可以被改)。
* ⇒ 地址必须是**运行期可配置 + 持久化**的:第一次打开问一次,之后一直用它。
*
* ⚠️ 为什么与令牌**分两个 preferences 文件**(`fb_auth` 存令牌 / `fb_server` 存地址):
* 两者生命周期不同 —— 「更换服务器」要清令牌(旧令牌在新服务器上无效),
* 但地址要保留(用户刚填的);「退出登录」清令牌但绝对不能清地址
* (否则下次打开又从头问一遍)。放同一个文件里,清/留很容易写错。
*
* ⚠️ 同样的"同步读"门面(见 FbTokenStore 的说明):
* `fbApi()` 是纯同步的拼 URL 逻辑,不可能为了一句地址把它改成 async。
* ⇒ 启动时 `init()` 预加载进内存,之后 `get()` 同步读。
* **前提**:必须在发第一个请求之前 init 完(见 `EntryAbility.onCreate`)。
* 调用方若拿不准,先用 `isReady()` 确认(见 `Index.bootCheck`)。
*/
import { preferences } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { common } from '@kit.AbilityKit';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbServer';
/** preferences 实例名(沙箱内的文件名) */
const STORE_NAME: string = 'fb_server';
/** 服务器基址的键名 */
const KEY_BASE: string = 'base_url';
/**
* 把用户输入整成可用的基址。
*
* 用户可能输的东西(都见过):
* `family.example.com` → 补 `https://`
* `family.example.com/` → 去掉尾斜杠
* `http://192.168.1.8:8080` → 原样(内网调试常用 http)
* `https://x.com/familybook/` → 保留子目录,只去尾斜杠
* ` https://x.com ` → 去首尾空白
* 空串 / 只有协议 → 返回空串(调用方据此判"没填")
*
* ⚠️ **只去尾部的 `/`**,不动路径中间的任何东西:
* 子目录部署(`https://x.com/familybook`)是真实存在的形态,
* 在这里"聪明地"截断成 `https://x.com` 会让请求全部 404。
* ⚠️ 默认补 `https://` 而不是 `http://`:
* 公网部署都应该有证书;内网调试时用户自己会输 `http://`(我们照抄不改)。
*/
export function fbNormalizeServer(input: string): string {
let s: string = input.trim();
if (s === '') {
return '';
}
// 协议:认 http / https(大小写都不管),其它一律当"没写协议"
if (!/^https?:\/\//i.test(s)) {
// 用户可能写了 `//x.com` 这种协议相对写法,先剥掉
if (s.startsWith('//')) {
s = s.substring(2);
}
s = 'https://' + s;
}
// 去尾部所有斜杠(`https://x.com///` → `https://x.com`)
while (s.endsWith('/')) {
s = s.substring(0, s.length - 1);
}
return s;
}
/**
* 是否是"看着像样"的基址。
*
* ⚠️ 这只是**形状检查**(拦住"只写了几个字母"这种明显错误),
* 真正的"这服务器对不对"必须靠 `fbProbeServer()` 打 `/api/v1/ping` ——
* 形状对但域名不存在/不是家书的服务器,这里一律判不出来。
*/
export function fbServerLooksValid(base: string): boolean {
// 主机部分:字母数字、点、横线、冒号(端口)、方括号(IPv6),且不能为空
return /^https?:\/\/[^\s/]+$/i.test(base);
}
/**
* 服务器地址存储(同步读、异步写的门面)。
*/
class FbServerStoreImpl {
private cached: string = '';
private store: preferences.Preferences | null = null;
private ready: boolean = false;
/** 预加载进内存。应在应用启动时调用一次(见 EntryAbility)。⚠️ 幂等 */
async init(ctx: common.Context): Promise<void> {
if (this.ready) {
return;
}
try {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
const v: preferences.ValueType = await this.store.get(KEY_BASE, '');
const raw: string = typeof v === 'string' ? v : '';
// ⚠️ 读出来也过一遍归一:老版本可能存了带尾斜杠的值
this.cached = fbNormalizeServer(raw);
this.ready = true;
hilog.info(DOMAIN, TAG, '服务器地址预加载完成,%{public}s',
this.cached === '' ? '未配置' : this.cached);
} catch (err) {
// 读不到就当"没配"——用户会被引导去填,比崩掉好
hilog.warn(DOMAIN, TAG, '服务器地址预加载失败:%{public}s', JSON.stringify(err));
this.ready = true;
}
}
/**
* 同步取基址(未配置返回空串)。
* ⚠️ 需先 init(),否则会误判成"未配置"(见 `isReady()`)。
*/
get(): string {
if (!this.ready) {
hilog.warn(DOMAIN, TAG, '服务器地址还没预加载完,本次读到的可能是空值');
}
return this.cached;
}
/** 有没有配置过服务器地址(同步) */
has(): boolean {
return this.cached !== '';
}
/** 预加载是否完成(同步)—— 启动分流必须看它,否则会闪一下"填地址"页 */
isReady(): boolean {
return this.ready;
}
/**
* 保存基址(先归一,再落盘)。
* 返回归一后的值(空串 = 输入不合法/为空,未写入)。
*/
async set(ctx: common.Context, input: string): Promise<string> {
const base: string = fbNormalizeServer(input);
if (!fbServerLooksValid(base)) {
return '';
}
try {
if (this.store === null) {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
}
await this.store.put(KEY_BASE, base);
// ⚠️ `put` 只写内存,**必须 `flush`** —— 不 flush 进程被杀就丢
// (表现:填了地址、重启后又让填)
await this.store.flush();
this.cached = base;
this.ready = true;
hilog.info(DOMAIN, TAG, '服务器地址已保存:%{public}s', base);
return base;
} catch (err) {
hilog.error(DOMAIN, TAG, '服务器地址保存失败:%{public}s', JSON.stringify(err));
return '';
}
}
/** 清除(一般只在"彻底重置"时用;「更换服务器」不该清空,直接覆盖) */
async clear(ctx: common.Context): Promise<void> {
try {
if (this.store === null) {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
}
await this.store.delete(KEY_BASE);
await this.store.flush();
this.cached = '';
hilog.info(DOMAIN, TAG, '服务器地址已清除');
} catch (err) {
hilog.error(DOMAIN, TAG, '服务器地址清除失败:%{public}s', JSON.stringify(err));
}
}
}
/** 全局单例 */
export const FB_SERVER_STORE: FbServerStoreImpl = new FbServerStoreImpl();
+227
View File
@@ -0,0 +1,227 @@
/**
* 家书 FamilyBook · 会话状态(鸿蒙端)
*
* 负责回答一个问题:**现在能不能用?**
*
* 🔴 v57(用户 2026-10-17)原话:
* 「这样一个 Token 我们给它 24 小时的寿命。就是每过 24 小时,我们就要请求一下
* 服务器,去验证一下这个 Token 是不是还是有效的。」
* 「只要是一直保持联网,就不需要每天都输密码。」
* 「如果手机丢了,我们就在网页端把这个设备去掉……这个应用就要自动退出,
* 退到登录页面。退到登录页面之后,里面所有的资源、所有的内容都是不允许看的。」
* 「运行期间任何一个请求返回 401,立刻锁定跳登录页。」
*
* 于是这里有**两种锁**,必须分开(混成一种就会违背"一直联网不用重输密码"):
*
* ┌─ need_login(必须重输密码)
* │ · 本地没有令牌
* │ · 服务端回 401(被踢 / 被吊销 / 账号停用)
* │ ⇒ 清掉本地令牌。v58 起还要删加密密钥。
* │
* └─ offline_expired(锁界面,但**不**清令牌)
* · 有令牌,但这台设备已经超过 24 小时没能跟服务器通过话
* ⇒ 只是"先别看了,联网确认一下"。一旦联网校验通过,**自动恢复**,
* 用户不用重新输密码 —— 这正是用户要的"一直联网就不用重登"。
*
* ⚠️ 离线时"过了多久"只能用**本机时钟**算(没网问不到服务器)。
* 所以把手机时间调回去是可以绕过的 —— 这是离线判定的固有缺陷,
* 由**服务端**兜住:`expires_at` 在服务端,联网后一律按服务端时间判。
* 端上这个计时只负责"离线期间别一直敞着"。
*/
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { fbApi, FbApiResult } from './FbHttp';
import { FB_TOKEN_STORE } from './FbTokenStore';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbSession';
const STORE_NAME: string = 'fb_session';
const KEY_VERIFIED_AT: string = 'verified_at_ms';
const KEY_SERVER_TIME: string = 'server_time';
/**
* 离线宽限:超过这么久没跟服务器通过话,就锁界面(用户定的 24 小时)。
*
* ⚠️ 与令牌的 24h 滑动窗口是**同一个数**,但不是同一件事:
* 令牌由服务端续期;这里量的是"端上最后一次成功校验距今多久"。
*/
export const OFFLINE_LIMIT_MS: number = 24 * 3600 * 1000;
/** 会话状态码 */
export const SESSION_OK: string = 'ok'; // 可用
export const SESSION_NEED_LOGIN: string = 'need_login'; // 必须重新登录
export const SESSION_OFFLINE: string = 'offline_expired'; // 离线超时,锁界面
/** `/me` 里我们用得到的字段 */
interface FbMeDevice {
expires_at?: string | null;
ttl_hours?: number;
id?: string;
name?: string;
}
interface FbMePayload {
server_time?: string;
device?: FbMeDevice;
}
/** 一次校验的结果 */
export interface FbSessionCheck {
/** 见上面的 SESSION_* 常量 */
state: string;
/** 给人看的原因('' = 正常) */
reason: string;
/** 服务端时间(ISO8601;拿不到为空) */
serverTime: string;
/** 续期后的令牌到期时间(拿不到为空) */
expiresAt: string;
}
class FbSessionImpl {
private store: preferences.Preferences | null = null;
private verifiedAtMs: number = 0;
private serverTime: string = '';
private ready: boolean = false;
/** 启动时预加载(与其它 store 同构) */
async init(ctx: common.Context): Promise<void> {
if (this.ready) {
return;
}
try {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
const v: preferences.ValueType = await this.store.get(KEY_VERIFIED_AT, 0);
this.verifiedAtMs = typeof v === 'number' ? v : 0;
const s: preferences.ValueType = await this.store.get(KEY_SERVER_TIME, '');
this.serverTime = typeof s === 'string' ? s : '';
this.ready = true;
} catch (err) {
hilog.warn(DOMAIN, TAG, '会话记录预加载失败:%{public}s', JSON.stringify(err));
this.ready = true;
}
}
/** 预加载是否完成 */
isReady(): boolean {
return this.ready;
}
/** 最后一次成功校验的时间(毫秒;0 = 从没校验过) */
lastVerifiedAt(): number {
return this.verifiedAtMs;
}
/** 离线已经多久(毫秒)。从没校验过时返回一个"足够大"的值 */
offlineMs(): number {
if (this.verifiedAtMs <= 0) {
return OFFLINE_LIMIT_MS + 1;
}
const d: number = Date.now() - this.verifiedAtMs;
return d > 0 ? d : 0;
}
/**
* 记一次"刚刚跟服务器通过话"。
*
* @param serverTime 服务端返回的时间(ISO8601)。**只用来展示/对账**,
* 不参与离线计时 —— 离线计时只能靠本机时钟(见文件头)。
*/
async markVerified(ctx: common.Context, serverTime: string): Promise<void> {
this.verifiedAtMs = Date.now();
if (serverTime !== '') {
this.serverTime = serverTime;
}
try {
if (this.store === null) {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
}
await this.store.put(KEY_VERIFIED_AT, this.verifiedAtMs);
await this.store.put(KEY_SERVER_TIME, this.serverTime);
await this.store.flush();
} catch (err) {
// 记不上就记不上:下次启动会算成"离线超时",用户联网一下就恢复
hilog.warn(DOMAIN, TAG, '会话时间保存失败:%{public}s', JSON.stringify(err));
}
}
/** 清掉校验记录(退出登录 / 被踢时) */
async clear(ctx: common.Context): Promise<void> {
this.verifiedAtMs = 0;
this.serverTime = '';
try {
if (this.store === null) {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
}
await this.store.delete(KEY_VERIFIED_AT);
await this.store.delete(KEY_SERVER_TIME);
await this.store.flush();
} catch (err) {
hilog.warn(DOMAIN, TAG, '会话记录清除失败:%{public}s', JSON.stringify(err));
}
}
/**
* 校验一次会话。
*
* 调用时机(两个,缺一不可):
* ① **每次启动**(用户 2026-10-17 明确要求);
* ② 前台恢复 / 用户手动"刷新"时(可选,见 Index)。
*
* ⚠️ 这里传 `authProbe: true`:401 由本方法自己处理(清令牌 + 返回 need_login),
* 不让网络层的全局回调再踢一次 —— 否则会出现"两个地方同时跳登录页"。
*/
async check(ctx: common.Context): Promise<FbSessionCheck> {
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
return { state: SESSION_NEED_LOGIN, reason: '还没登录', serverTime: '', expiresAt: '' };
}
const r: FbApiResult = await fbApi('/api/v1/me', { token: token, authProbe: true });
if (r.status === 200 && r.ok) {
let payload: FbMePayload = {};
try {
payload = JSON.parse(r.data) as FbMePayload;
} catch (e) {
payload = {};
}
const st: string = payload.server_time !== undefined ? payload.server_time : '';
await this.markVerified(ctx, st);
const exp: string = (payload.device !== undefined && payload.device.expires_at !== null
&& payload.device.expires_at !== undefined) ? payload.device.expires_at : '';
return { state: SESSION_OK, reason: '', serverTime: st, expiresAt: exp };
}
if (r.status === 401) {
// 被踢 / 被吊销 / 过期 / 账号停用 —— 一律当"必须重新登录"
await FB_TOKEN_STORE.clear(ctx);
await this.clear(ctx);
hilog.info(DOMAIN, TAG, '令牌已失效(可能被网页端远程移除了设备)');
return {
state: SESSION_NEED_LOGIN,
reason: '这台设备已经被退出登录,请重新登录',
serverTime: '',
expiresAt: ''
};
}
/* 网络层失败(断网/超时/服务器挂了)—— **不能**当成"被踢",
否则用户坐一趟飞机回来就得重输密码。
只按"离线多久"决定要不要锁。 */
const off: number = this.offlineMs();
if (off > OFFLINE_LIMIT_MS) {
return {
state: SESSION_OFFLINE,
reason: '已经超过 24 小时没能连上服务器,需要联网确认一次',
serverTime: this.serverTime,
expiresAt: ''
};
}
return { state: SESSION_OK, reason: '离线可用', serverTime: this.serverTime, expiresAt: '' };
}
}
/** 全局单例 */
export const FB_SESSION: FbSessionImpl = new FbSessionImpl();
+176
View File
@@ -0,0 +1,176 @@
/**
* 家书 FamilyBook · 全量同步(鸿蒙端)
*
* 一次把所有模块都同步一遍。
*
* 🔴 为什么需要它(用户 2026-10-12:"把同步做成全局的,接进来一个模块,
* 就自动同步一个模块"):
* 之前每个页面**自己同步自己**(进入观影页同步观影、进名册页同步名册),
* 结果是"用户必须把每个页面都点一遍才拿到全量数据",
* 而且设置页那个「保存并同步」只同步了观影 —— 一个入口只干一部分活,
* 用户没法理解。
* ⇒ 加这个"全量同步":**一处触发、全部模块都更新**。
*
* ⚠️ 它与"页面各自同步"**不冲突**,两者都要有:
* · 页面各自同步:保证"刚进这一页就有最新数据";
* · 全量同步:保证"不用点遍每一页"。
* 两条路的终点是同一个缓存(`module_cache` / 实体表),所以谁先谁后都对。
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbMovieRepo, FbSyncResult } from './FbMovieRepo';
import { FbPersonRepo, FbRosterSyncResult } from './FbPersonRepo';
import { FbModuleLoad, FbModuleRepo } from './FbModuleRepo';
import { FB_MODULE_PATH } from './FbModuleLoad';
import { FB_TOKEN_STORE } from './FbTokenStore';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbSyncAll';
/** 全量同步结果 */
export interface FbSyncAllResult {
ok: boolean;
/** 成功同步的模块数 */
done: number;
/** 总模块数 */
total: number;
/** 失败模块名(用户看得到的人话) */
failed: string[];
/** 当前进度文案(同步中实时更新用) */
note: string;
}
/**
* 全量同步的实时进度(供 UI 显示)。
*
* ⚠️ 为什么用一个**模块级变量**而不是回调:
* 本机设备 `hilog` 读不出内容(已知限制),
* 同步又可能跑十几秒 —— 页面必须能显示"现在到哪一步了",
* 否则用户以为卡死。用轮询读这个变量的方式最省事(见设置页)。
*/
export let FB_SYNC_ALL_PROGRESS: string = '';
/** 模块的中文名(进度提示用) */
const MODULE_CN: Record<string, string> = {
'movies': '观影',
'roster': '名册',
'calendar': '日程',
'tasks': '任务',
'days': '纪念日',
'trips': '旅程',
'ledger': '账本',
'resources': '资源',
'books': '藏书',
'notes': '笔记',
'notifications': '通知',
'home': '首页',
};
/**
* 进度回调(可选)。
*
* 🔴 为什么加这个参数,而不是让调用方去轮询 `FB_SYNC_ALL_PROGRESS`:
* 模块级 `export let` 的"实时值"依赖打包器是否保留 live binding ——
* 这在 esmodule / 混淆 / tree-shaking 下**不保证**,一旦被内联成常量,
* 进度条就永远停在初始值(而调用方还以为在正常工作)。
* ⇒ 登录页这种"用户正盯着屏幕等"的场景,进度必须**从函数里推出来**。
*
* @param label 正在同步的模块中文名(如「观影」)
* @param done 已完成的模块数(**不含**当前这个)
* @param total 模块总数
*/
export type FbSyncStepCb = (label: string, done: number, total: number) => void;
export async function syncAll(onStep?: FbSyncStepCb): Promise<FbSyncAllResult> {
const token: string = FB_TOKEN_STORE.get();
const failed: string[] = [];
if (token === '') {
const r: FbSyncAllResult = {
ok: false, done: 0, total: 12, failed: [],
note: '未登录,无法同步',
};
FB_SYNC_ALL_PROGRESS = r.note;
return r;
}
let done: number = 0;
const total: number = 12;
/** 跑一个模块并记账 */
const step = async (label: string, work: () => Promise<boolean>): Promise<void> => {
FB_SYNC_ALL_PROGRESS = label;
if (onStep !== undefined) {
onStep(label, done, total);
}
try {
const ok: boolean = await work();
if (ok) {
done += 1;
} else {
failed.push(label);
}
} catch (err) {
hilog.warn(DOMAIN, TAG, '同步 %{public}s 失败:%{public}s', label, JSON.stringify(err));
failed.push(label);
}
};
// ---- ① 有实体表的两个模块(先做:它们的数据量大、最该先落地)----
await step(MODULE_CN['movies'], async (): Promise<boolean> => {
const r: FbSyncResult = await FbMovieRepo.inst().sync(token);
return r.ok;
});
await step(MODULE_CN['roster'], async (): Promise<boolean> => {
const r: FbRosterSyncResult = await FbPersonRepo.inst().sync(token);
return r.ok;
});
// ---- ② 其余模块(走通用缓存)----
const cacheModules: string[] = [
'calendar', 'tasks', 'days', 'trips', 'ledger', 'resources', 'books', 'notes',
'notifications', 'home',
];
const repo: FbModuleRepo = FbModuleRepo.inst();
for (const name of cacheModules) {
const label: string = MODULE_CN[name] !== undefined ? MODULE_CN[name] : name;
const path: string = FB_MODULE_PATH[name] !== undefined ? FB_MODULE_PATH[name] : '';
if (path === '') {
continue;
}
await step(label, async (): Promise<boolean> => {
// 这里只关心"有没有成功写进缓存":`load` 的 sink 回调是同步的,
// 用一个 Promise 把它包起来,等到网络那次回来(fromCache=false)才算成功。
const ok: boolean = await new Promise<boolean>((resolve: (v: boolean) => void): void => {
let settled: boolean = false;
repo.load(name, path, (r: FbModuleLoad): void => {
if (r.fromCache || settled) {
return; // 缓存那次不算结果
}
settled = true;
resolve(r.error === '');
});
// 兜底超时(网络卡住时不能让全量同步永远挂着)
setTimeout((): void => {
if (!settled) {
settled = true;
resolve(false);
}
}, 20000);
});
return ok;
});
}
const result: FbSyncAllResult = {
ok: failed.length === 0,
done: done,
total: total,
failed: failed,
note: failed.length === 0
? ('全部 ' + done.toString() + ' 个模块同步完成')
: (done.toString() + '/' + total.toString() + ' 完成,失败:' + failed.join('、')),
};
FB_SYNC_ALL_PROGRESS = result.note;
return result;
}
+131
View File
@@ -0,0 +1,131 @@
/**
* 家书 FamilyBook · API 令牌存储(鸿蒙端)
*
* 只存一件事:移动端 API 令牌(`api_tokens` 表签发的那一串)。
*
* 🔴 为什么用 `preferences` 而不是本地库 RDB:
* 这是**单个键值**(一把令牌),不是"多行记录"。
* KV 存储天生适合,也不必为它建表、迁移。
* 令牌**不参与同步**(服务端 v33 的 53 张业务表里也不含 api_tokens)——
* 它本来就是"这台设备与服务器之间的凭据",各设备各自一份。
*
* ⚠️ 令牌是敏感凭证:
* · 放 `preferences` 会落在应用沙箱内(其它应用读不到),这对家庭应用够用;
* · 若将来要更严(防 root 提取),应迁到 `@ohos.security.asset`(系统密钥库)。
* 现在不做的原因:Asset Store 的 API 面更复杂,而本应用的数据敏感度
* (家庭日程/观影)还不至于需要它。**这是个有意识的取舍,不是遗漏。**
* · ⚠️ 无论如何**不要**把令牌写进日志或错误信息里往外抛。
*/
import { preferences } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { common } from '@kit.AbilityKit';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbToken';
/** preferences 实例名(沙箱内的文件名) */
const STORE_NAME: string = 'fb_auth';
/** 令牌的键名 */
const KEY_TOKEN: string = 'api_token';
/**
* 令牌存储(同步读、异步写的门面)。
*
* 🔴 为什么提供一个**同步的 `get()`**:
* 页面渲染时经常需要立刻知道"有没有令牌"(决定要不要发起同步)。
* 若只能异步读,每个调用点都要 await,把简单逻辑搞得支离破碎。
* ⇒ 用"启动时预加载进内存 + 写时同时更新内存"的方式提供同步读。
* ⚠️ 前提是**启动时必须先 `init()`**,否则内存里是空的(会误判成"未登录")。
*/
class FbTokenStoreImpl {
private cached: string = '';
private store: preferences.Preferences | null = null;
private ready: boolean = false;
/**
* 预加载令牌进内存。应在应用启动时调用一次(见 EntryAbility)。
* ⚠️ 幂等:重复调用直接返回,不会重复读盘。
*/
async init(ctx: common.Context): Promise<void> {
if (this.ready) {
return;
}
try {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
const v: preferences.ValueType = await this.store.get(KEY_TOKEN, '');
this.cached = typeof v === 'string' ? v : '';
this.ready = true;
hilog.info(DOMAIN, TAG, '令牌预加载完成,%{public}s',
this.cached === '' ? '未设置' : '已设置');
} catch (err) {
// ⚠️ 读不到就当"没有令牌",**不要**让启动流程挂掉 ——
// 用户仍能用本地数据(本地优先的好处)。
hilog.warn(DOMAIN, TAG, '令牌预加载失败:%{public}s', JSON.stringify(err));
this.ready = true;
}
}
/** 同步取令牌(未设置返回空串)。⚠️ 需先 init(),否则会误判为未登录 */
get(): string {
if (!this.ready) {
hilog.warn(DOMAIN, TAG, '令牌还没预加载完,本次读到的可能是空值');
}
return this.cached;
}
/** 是否已设置令牌(同步) */
has(): boolean {
return this.cached !== '';
}
/**
* 预加载是否已完成(同步)。
*
* 🔴 为什么需要它:`get()` 在**没预加载完**时会返回空串(看起来像"未登录")。
* 启动流程里若据此判断,就会出现"明明有令牌却显示登录页"。
* ⇒ 调用方应先用 `isReady()` 确认预加载完成,再做登录态判断。
* (见 Index.bootCheck 里的等待循环。)
*/
isReady(): boolean {
return this.ready;
}
/** 保存令牌(同时更新内存缓存与磁盘) */
async set(ctx: common.Context, token: string): Promise<boolean> {
try {
if (this.store === null) {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
}
await this.store.put(KEY_TOKEN, token);
// ⚠️ `put` 只写内存,**必须 `flush`** 才落盘 ——
// 不 flush 的话进程被杀就丢了(表现为"登录后又变未登录")。
await this.store.flush();
this.cached = token;
this.ready = true;
hilog.info(DOMAIN, TAG, '令牌已保存');
return true;
} catch (err) {
hilog.error(DOMAIN, TAG, '令牌保存失败:%{public}s', JSON.stringify(err));
return false;
}
}
/** 清除令牌(登出) */
async clear(ctx: common.Context): Promise<void> {
try {
if (this.store === null) {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
}
await this.store.delete(KEY_TOKEN);
await this.store.flush();
this.cached = '';
hilog.info(DOMAIN, TAG, '令牌已清除');
} catch (err) {
hilog.error(DOMAIN, TAG, '令牌清除失败:%{public}s', JSON.stringify(err));
}
}
}
/** 全局单例 */
export const FB_TOKEN_STORE: FbTokenStoreImpl = new FbTokenStoreImpl();