Files
FamilyBook_harmonyos/entry/src/main/ets/data/FbMovieRepo.ets
T
laoyang 2132ee42d2 v0.8.0 本地加密 / 被踢清数据 / 增量同步 / 附件队列 / 顶栏同步进度
用户 2026-10-18 两条:
「直接做 L3 锁界面 + 本地加密(HUKS,踢设备即吊销密钥):数据在但读不出,
  重登后恢复密钥即可用。」
「右上角头像和提醒前面加同步进度…首次全量、之后增量;进来先展示本地数据,
  一边同步一边更新;附件后台队列同步(名册头像 / 电影海报 / 观影照片 / 资源附件)。」

① 整库加密(FbSchema v4→v5,新增 module_sync / attach_cache 两张表)
   RDB encrypt=true。加密属性一经创建不可修改 ⇒ openWithEncryption 三段式:
   直接开 → 失败删库重建 → 再失败降级明文(degraded 字段,设置页如实显示,
   不静默降级)。

② 两层密钥(FbKeyStore)
   HUKS 里 KEK(fb_kek_v1,不可导出)包裹 32 字节 DEK;revoke() 删 KEK
   ⇒ 已落盘附件密文在数学上解不开,这就是"踢设备即吊销密钥"。
   重新登录重新生成 DEK 并重下附件(不做服务端托管,为了省一次重下
   把能解你本地数据的密钥放到服务端不值)。
   会话属性里 PURPOSE 只出现一次:原来把含 ENCRYPT|DECRYPT 的生成属性直接
   拿去开会话再 push 一个 PURPOSE,同一 tag 出现两次在 HUKS 里行为未定义
   ⇒ 拆成 kekBaseProps / kekGenProps / kekSessionProps。

③ AES-256-GCM(FbCrypto)
   落盘 iv(12)|ct|tag(16);变换串必须带 padding 段(AES256|GCM|PKCS7,
   缺了抛 801);解密从尾部切 tag。

④ 附件后台队列(FbAttachQueue)
   attach_cache 台账(pending/done/fail,MAX_TRIES=3);密文放 filesDir、
   明文临时放 cacheDir;扩展名白名单筛 key。
   图片地址收成统一入口 fbImageSrc(key,token):本地有就用 file://,
   没有才回网络 —— 收成一个函数才漏不掉(5 个页面全部改过来)。

⑤ 被踢 / 退出清数据
   lockOut → 清令牌 → FB_ATTACH.wipe(第一步就是吊销 KEK)→ FbDb.wipe
   (先关句柄再 deleteRdbStore,并发去重,open 会等它)→ 同步状态复位。
   重新登录后重建密钥 + 库 + 附件索引,否则之后只读网络不写缓存且不报错。

⑥ 增量同步(FbSyncAll 重写)
   先问 /api/v1/changes;指纹相同就跳过。本地没水位必须拉(否则老用户升级后
   一个模块都不同步);24h 陈旧上限兜"改无 updated_at 表的已有行";
   /changes 拿不到时判 ping status>0 —— 断网直接收工,不退回全量
   (否则 12 个模块各等 15 秒)。
   家谱也进了预同步列表(原来只在打开时拉,离线是一片空白);
   总步数由 CACHE_MODULES.length 算出,不写死。

⑦ 删除传播
   服务端是物理删除、没有墓碑 ⇒ 观影同步改成"先清后插"
   (persons 跨模块共用,不清)。

⑧ 顶栏同步进度
   头像与铃铛之间加三态按钮(转圈 / 红感叹号 / 灰对勾)+ bindSheet 面板:
   进度条、「N 个模块没有变化」、附件行、失败清单、立即同步(防连点)。
   状态中心 FbSyncState(@ObservedV2 + @Trace,AppStorageV2 单例);
   界面直接读 @Trace 字段,不提供派生 getter。

验证:hvigor BUILD SUCCESSFUL;_fb_arkts_guard.js 290/0(本批新增 59 条);
_fb_v58_falsify.py 31 项注入 38/0;b57 反向验证 12/0(锚点跟上本批 logout 改动);
本地 e2e 1519/0;全量流水线 PUSH_RC=0。
2026-10-18 16:01:41 +08:00

911 lines
34 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 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();
/* 🔴 v59:先清掉上一次的观影数据,再整批写入 —— 这是**删除传播**的实现。
*
* 之前只做 upsert(`INSERT OR REPLACE`),于是"在网页上删掉的那一场电影"
* 在端上永远留着 —— 用户看到的是已经不存在的记录,还以为同步坏了。
* 服务端是**物理删除**(没有 `deleted_at`),所以没有墓碑可拉,
* 客户端唯一可靠的判断方式就是"这次没返回 = 已被删"。
*
* ⚠️ 为什么敢在"拉取成功之后、写入之前"清:
* 上面所有失败分支都已经 `return` 了 —— 走到这里说明**服务端数据完整到手**。
* 拉取失败时绝不会清,用户手上那份旧数据仍然可用。
* ⚠️ 为什么不清 `persons`:它是跨模块共用表(名册/观影都写),
* 清它等于把名册也清了。观影同看人残留几条无害(名册同步会覆盖)。
* ⚠️ 清 `events` 只清 `kind='movie'`:日程模块将来写入的条目不能被观影同步顺手删掉。
*/
await this.db.exec("DELETE FROM events WHERE kind = 'movie'");
await this.db.exec('DELETE FROM movie_details');
await this.db.exec('DELETE FROM event_attendees');
await this.db.exec('DELETE FROM cinemas');
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
};
}