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