Files
FamilyBook_harmonyos/entry/src/main/ets/data/FbMovieRepo.ets
T
laoyang f1f279f973 电影影评+评论:详情浮层接「评价与评论」真功能 laoyang/FamilyBook#27
用户原话(2026-10-28):「鸿蒙端对齐网页端」。观影详情原来只能看平均分。
现在:

1) FbMovieRepo 加影评/评论类型 + reviews/saveReview/deleteReview/
   addReviewComment/deleteReviewComment 五个 API + 解析(含感受目录、评论树、mine 判断)
2) 新组件 FbMovieReviewSheet:我的影评(打分 1-10 星 + 观后感受多选 + 正文 +
   改/删)、全部影评列表(含评论树、回复、逐条删除)
3) FbMovieDetailSheet 加「评价与评论」入口 + 内嵌影评浮层

⚠️ 影评数/均分仍以列表项为准(详情接口不返回,与既有注释一致)。

编译 BUILD SUCCESSFUL;服务端冒烟 _i27_movie_review_api.py 22/0 + 反证命中。
2026-10-28 22:30:09 +08:00

1660 lines
63 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';
import { fbUploadOne } from './FbUpload';
import { FB_TOKEN_STORE } from './FbTokenStore';
import { fbAppState } from '../common/Store';
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;
}
/* ===================== v62:影院管理的统计 / 报告 / 搜索 ===================== */
/**
* 观影总览(`MovieStats::overview`)—— 与网页端**同一份实现**,数字必须一致。
*/
export interface FbMovieOverview {
movies?: number;
cinemas?: number;
persons?: number;
viewership?: number;
first_date?: string | null;
last_date?: string | null;
}
/** 按影院计数(`MovieStats::byCinema`) */
export interface FbMovieCinemaStat {
id?: number;
name?: string;
place?: string;
color?: string;
cnt?: number;
}
/** 按月计数(`MovieStats::byMonth`) */
export interface FbMovieMonthStat {
ym?: string;
label?: string;
cnt?: number;
}
/** 按年计数(`MovieStats::byYear`) */
export interface FbMovieYearStat {
year?: number;
cnt?: number;
}
/**
* 「记一场」表单填出来的草稿(→ `createMovie`)。
*
* ⚠️ 字段名与 `createMovie` 的请求体一一对应(那里再映射成服务端的下划线命名)。
*/
export interface FbMovieDraft {
/** 片名(必填) */
title: string;
/** 开始时间 `YYYY-MM-DD HH:MM`(必填;服务端会补秒) */
startsAt: string;
/** 结束时间(可空) */
endsAt: string;
/** 地点(服务端字段是 `location`;可空) */
location: string;
/** 备注(可空) */
note: string;
/** 剧情简介(可空) */
info: string;
/** 影院 id(0 = 没选) */
cinemaId: number;
/** 同看人的 person id 列表 */
attendees: number[];
/**
* 海报存储键(`posters/xxx.jpg`)。
*
* 🔴 v64:**豆瓣抓取来的海报**走的不是"上传文件"那条路 ——
* 服务端抓取时已经把图落盘了,返回一个键;端上保存记录时把它带上即可
* (与网页端隐藏字段 `poster_key` 完全同一条路,见 `movies/create` 路由)。
* ⚠️ 端上自己选的图片则走 `uploadPoster()` 单独上传(记录建完之后),
* 两种来源**不会同时用**:抓来的那份只有键、端上选的那份是字节。
*/
posterKey: string;
}
/** 豆瓣片名联想的一条候选(`/movies/douban?action=suggest`) */
export interface FbDoubanSuggest {
id?: string;
title?: string;
sub_title?: string;
year?: string;
kind?: string;
/** 缩略图地址(**已是 API 代理形式**,端上补域名 + token 即可加载) */
thumb?: string;
}
/** 豆瓣抓取的结果(`/movies/douban`) */
export interface FbDoubanFetch {
/** 豆瓣正规片名(回填输入框) */
title: string;
/** **服务端拼好的**简介全文(含导演/主演/年份/类型/片长/国家/评分 + 剧情) */
summary: string;
/** 已落盘的海报存储键(`posters/xxx.jpg`;没抓到就是空串) */
posterKey: string;
/** 海报可访问地址(预览用) */
posterUrl: string;
/** 豆瓣元数据 JSON 串(原样存进 `douban_meta`,详情页会另有一块展示) */
meta: string;
}
/** `/api/v1/movies/stats` 的完整载荷 */
export interface FbMovieStats {
overview?: FbMovieOverview;
by_cinema?: FbMovieCinemaStat[];
by_person?: Object[];
by_month?: FbMovieMonthStat[];
by_year?: FbMovieYearStat[];
}
/**
* 年度报告(`MovieStats::annual`)—— 以登录者为中心,只统计他参与过的场次。
*
* ⚠️ 字段很多(月份分布 / 星期分布 / 最常去的影院 / 一起看最多的人……),
* 这里只声明**端上要显示的那几个**,其余原样留在 JSON 里不取。
*/
export interface FbMovieReportStats {
total?: number;
days?: number;
top_cinema?: string;
top_cinema_count?: number;
top_person?: string;
top_person_count?: number;
first_date?: string | null;
last_date?: string | null;
most_viewers_movie?: string;
most_viewers_count?: number;
avg_per_month?: number;
avg_days_between?: number;
month_distribution?: Record<string, number>;
weekday_distribution?: Record<string, number>;
keywords?: string[];
recent_movies?: Object[];
my_movies?: Object[];
common_viewers?: Object[];
}
export interface FbMovieReport {
year?: number;
years?: number[];
stats?: FbMovieReportStats;
}
/** 高级搜索的条件(全空 = 不查,与网页 `hasCond` 同一行为) */
export interface FbMovieSearchParams {
title: string;
cinemaId: number;
personId: number;
dateFrom: string;
dateTo: string;
}
/** 高级搜索返回的一行(服务端 `MovieStats::search` 的字段) */
export interface FbApiMovieSearchRow {
id?: number;
title?: string;
starts_at?: string;
ends_at?: string;
poster_key?: string | null;
cinema_name?: string | null;
cinema_color?: string | null;
attendee_names?: string | null;
}
/** 观影详情(点卡片后展示) */
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[];
}
// ============================================================
// 影评与评论(工单 #27)
// ============================================================
/** 一条评论(服务端 movie_review_comments) */
export interface FbMovieComment {
id: number;
/** 评论人 */
name: string;
color: string;
content: string;
/** 回复的人(空 = 顶层评论) */
replyTo: string;
/** 时间 */
createdAt: string;
/** 是不是我发的(决定能否删) */
mine: boolean;
/** 回复列表 */
replies: FbMovieComment[];
}
/** 一条影评(服务端 movie_reviews + 评论树) */
export interface FbMovieReview {
id: number;
/** 评价人 */
name: string;
color: string;
rating: number;
content: string;
/** 感受(key 数组,端上查 catalog 显示 emoji + 文案) */
feelings: string[];
createdAt: string;
/** 是不是我写的(决定能否删/改) */
mine: boolean;
comments: FbMovieComment[];
}
/** 影评区的完整载荷(`GET /movies/<id>/reviews`) */
export interface FbMovieReviews {
reviews: FbMovieReview[];
/** 我的影评(null = 没写) */
mine: FbMovieReview | null;
/** 条数 / 均分 */
count: number;
avg: number;
/** 感受目录:key -> {emoji,label} */
catalog: Record<string, 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;
}
/**
* **新建一场观影**(用户 2026-10-19:「电影新增一场、新增一个电影,
* 也就是说"记一场"这个功能现在可以开始做了。」)
*
* 🔴 为什么必须联网、且**不等同步**:
* 这是"写"操作 —— 写进去才算数。接口成功后**立刻重拉列表**回写本地库,
* 否则用户刚记的那场要等下次整批同步才出现(看着像没保存)。
*
* ⚠️ 字段名必须与服务端 `ApiHandler::movieData()` 逐字一致:
* `title` / `starts_at` / `ends_at` / `location` / `note` / `info` /
* `cinema_id` / `attendees`(int[])。
* ⚠️ `starts_at` 服务端走 `Api::dt()`,要 `YYYY-MM-DD HH:MM:SS` 或
* `YYYY-MM-DD HH:MM`(端上拼第二种,服务端会补秒)。
*
* @returns 新建成功返回新 id(>0);失败返回 0,原因见 `lastError`
*/
async createMovie(d: FbMovieDraft, token: string): Promise<number> {
if (token === '') {
this.lastError = '还没登录';
return 0;
}
if (d.title.trim() === '') {
this.lastError = '片名不能为空';
return 0;
}
if (d.startsAt.trim() === '') {
this.lastError = '观影时间不能为空';
return 0;
}
/* ⚠️ 请求体要用**显式声明的 interface**(`arkts-no-untyped-obj-literals`)——
这里用 `Record<string, Object>` 逐键赋值(与 `FbCalRepo.toBody` 同一写法)。 */
const body: Record<string, Object> = {};
body['title'] = d.title.trim();
body['starts_at'] = d.startsAt.trim();
body['ends_at'] = d.endsAt.trim();
body['location'] = d.location.trim();
body['note'] = d.note.trim();
body['info'] = d.info.trim();
body['cinema_id'] = d.cinemaId;
body['attendees'] = d.attendees;
/* 🔴 v64:豆瓣抓来的海报键随记录一起提交(与网页端隐藏字段同一条路)。
⚠️ 只有非空才发 —— 发空串会**清掉**已有的海报(服务端的 `movieData` 认
"字段存在"为"要改")。 */
if (d.posterKey !== '') {
body['poster_key'] = d.posterKey;
}
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
const r: FbApiResult = await fbApi('/api/v1/movies', opts);
if (!r.ok) {
this.lastError = r.error !== '' ? r.error : '保存失败';
return 0;
}
let newId: number = 0;
try {
const o: Record<string, Object> = JSON.parse(r.data) as Record<string, Object>;
const v: Object | undefined = o['id'];
newId = typeof v === 'number' ? v : 0;
} catch (err) {
hilog.warn(DOMAIN, TAG, '新建观影回包解析失败:%{public}s', JSON.stringify(err));
}
this.lastError = '';
// 🔴 立刻重拉列表回写本地库(见方法说明)
await this.sync(token);
return newId;
}
/**
* 上一次写操作的错误原因('' = 成功)。
*
* ⚠️ 不用"返回 string 错误 + 用 id 表示成功"那种混合签名:
* `createMovie` 要**返回新 id**,错误就只好另找地方放(本项目在别处踩过)。
*/
lastError: string = '';
/**
* 新建 / 修改一家影院(**直接打接口,不等同步**)。
*
* 🔴 v62(用户 2026-10-19):「设置里面的影院管理,这些功能都还没有,
* 应该补充,就是增加影院呢、修改影院呢……」
* ⇒ 影院**本来只能被动等观影同步带下来**(`sync()` 里那段),
* 用户想"先建一家影院、再往里记片子"是做不到的。
*
* ⚠️ 服务端**早就有** CRUD(`cinemas()` 支持 POST/PATCH/DELETE),
* 缺的只是端上没接 —— 所以这里不需要动服务端,只要把它接上。
* ⚠️ 写完**立刻拉一次影院列表回写本地库**:否则用户新建完,
* 这个浮层还显示旧列表(他刚建的那家要等下次同步才出现,看着像失败)。
* ⇒ 与"本地优先"不冲突:本地优先说的是**读**走本地,
* 而写操作本来就必须联网(写不进去时本地也不该装作成功)。
*
* @param id 0 = 新建;> 0 = 修改
* @returns 错误文案('' = 成功)
*/
async saveCinema(id: number, place: string, name: string,
address: string, color: string): Promise<string> {
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
return '还没登录';
}
if (name.trim() === '') {
return '影院名称不能为空';
}
const body: Record<string, Object> = {};
body['place'] = place.trim();
body['name'] = name.trim();
body['address'] = address.trim();
body['color'] = color;
const opts: FbApiOptions = id > 0
? { method: 'PATCH', token: token, body: body }
: { method: 'POST', token: token, body: body };
const r: FbApiResult = id > 0
? await fbApi('/api/v1/cinemas/' + id.toString(), opts)
: await fbApi('/api/v1/cinemas', opts);
if (!r.ok) {
return r.error !== '' ? r.error : '保存失败';
}
// 立刻回写本地库(见方法说明:不回写的话浮层还显示旧列表)
await this.refreshCinemas(token);
return '';
}
/** 删一家影院(服务端会把关联观影记录的 `cinema_id` 置空,历史记录保留) */
async deleteCinema(id: number): Promise<string> {
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
return '还没登录';
}
const r: FbApiResult = await fbApi('/api/v1/cinemas/' + id.toString(),
{ method: 'DELETE', token: token });
if (!r.ok) {
return r.error !== '' ? r.error : '删除失败';
}
await this.refreshCinemas(token);
return '';
}
/**
* 重新拉一次影院列表并覆盖本地 `cinemas` 表。
*
* ⚠️ 用 `INSERT OR REPLACE` 而不是"先清空再插":清空再插会在中途失败时
* 留下一个空表(用户的影院列表整个消失),而 REPLACE 最差也只是旧的没更新。
*/
async refreshCinemas(token?: string): Promise<number> {
const tk: string = token !== undefined ? token : FB_TOKEN_STORE.get();
if (tk === '') {
return 0;
}
const r: FbApiResult = await fbApi('/api/v1/cinemas', { method: 'GET', token: tk });
if (!r.ok) {
return 0;
}
const rows: FbCinemaRow[] = parseCinemaRows(r.data);
for (const c of rows) {
await this.db.exec(
'INSERT OR REPLACE INTO cinemas (id, family_id, place, name, address, color,'
+ ' created_at, updated_at, deleted_at) VALUES (?,?,?,?,?,?,?,?,?)',
[c.id, c.familyId, c.place, c.name, c.address, c.color,
c.createdAt, c.updatedAt, c.deletedAt]
);
}
return rows.length;
}
/**
* 观影统计(家族级)—— 走 `/api/v1/movies/stats`。
*
* 🔴 为什么不从本地库算:统计口径必须与**网页端**一致
* (同一份 `MovieStats`)。本地库算出来的是"已同步的那部分",
* 少同步一次就少一部 —— 用户会发现"网页 124 部、App 121 部"。
* ⇒ 统计**必须联网**(它本来就是"看一眼现状",不要求断网可用)。
*/
async stats(token: string): Promise<FbMovieStats | null> {
const r: FbApiResult = await fbApi('/api/v1/movies/stats', { method: 'GET', token: token });
if (!r.ok) {
return null;
}
try {
return JSON.parse(r.data) as FbMovieStats;
} catch (err) {
hilog.error(DOMAIN, TAG, '统计解析失败:%{public}s', JSON.stringify(err));
return null;
}
}
/** 年度报告(以登录者为中心)—— 走 `/api/v1/movies/report` */
async report(token: string, year: number): Promise<FbMovieReport | null> {
/* ⚠️ `query` 必须走**显式声明类型**的变量:ArkTS 的
`arkts-no-untyped-obj-literals` 不允许把裸对象字面量直接传进
`Record<string, string>` 形参(本项目已踩过同一条,见 FbAccountPage)。 */
const q: Record<string, string> = {};
q['year'] = year.toString();
const r: FbApiResult = await fbApi('/api/v1/movies/report',
{ method: 'GET', token: token, query: q });
if (!r.ok) {
return null;
}
try {
return JSON.parse(r.data) as FbMovieReport;
} catch (err) {
hilog.error(DOMAIN, TAG, '年度报告解析失败:%{public}s', JSON.stringify(err));
return null;
}
}
/** 高级搜索 —— 走 `/api/v1/movies/search` */
async search(token: string, p: FbMovieSearchParams): Promise<FbApiMovieSearchRow[]> {
const q: Record<string, string> = {};
if (p.title !== '') { q['title'] = p.title; }
if (p.cinemaId > 0) { q['cinema_id'] = p.cinemaId.toString(); }
if (p.personId > 0) { q['person_id'] = p.personId.toString(); }
if (p.dateFrom !== '') { q['date_from'] = p.dateFrom; }
if (p.dateTo !== '') { q['date_to'] = p.dateTo; }
const r: FbApiResult = await fbApi('/api/v1/movies/search',
{ method: 'GET', token: token, query: q });
if (!r.ok) {
return [];
}
try {
const o: Record<string, Object> = JSON.parse(r.data) as Record<string, Object>;
const arr: Object[] | undefined = o['rows'] as Object[] | undefined;
return arr !== undefined && Array.isArray(arr) ? (arr as FbApiMovieSearchRow[]) : [];
} catch (err) {
hilog.error(DOMAIN, TAG, '搜索解析失败:%{public}s', JSON.stringify(err));
return [];
}
}
/* ============================================================
🔴 v64:豆瓣自动补充(片名联想 / 一键抓取简介 + 海报)
============================================================ */
/**
* 片名联想(`/api/v1/movies/douban?action=suggest&q=…`)。
*
* 🔴 用户 2026-10-19:「添加电影这里,**输入片名可以通过豆瓣的地址自动获取简介和海报**。
* 这个在网页端是直接实现了的,我们的手机端也必须同步实现。」
*
* ⚠️ 与网页端**同一个上游**(`App\Douban::suggest`),候选的顺序、过滤影人、
* 限量 8 条的规则完全一致 —— 两边看到的候选不该有差别。
* ⚠️ 失败**静默**(返回空数组):联想是"打字时的辅助",弹个错误框会
* 打断输入;真正的错误在用户点「自动获取」时给(与网页端同一取舍)。
*
* @returns 候选列表(最多 8 条;失败或没搜到都是空数组)
*/
async doubanSuggest(q: string, token: string): Promise<FbDoubanSuggest[]> {
const kw: string = q.trim();
if (kw === '') {
return [];
}
const query: Record<string, string> = {};
query['action'] = 'suggest';
query['q'] = kw;
const r: FbApiResult = await fbApi('/api/v1/movies/douban',
{ method: 'GET', token: token, query: query });
if (!r.ok) {
hilog.warn(DOMAIN, TAG, '豆瓣联想失败(静默):%{public}s', r.error);
return [];
}
try {
const o: Record<string, Object> = JSON.parse(r.data) as Record<string, Object>;
const arr: Object[] | undefined = o['items'] as Object[] | undefined;
return arr !== undefined && Array.isArray(arr) ? (arr as FbDoubanSuggest[]) : [];
} catch (err) {
hilog.warn(DOMAIN, TAG, '豆瓣联想解析失败:%{public}s', JSON.stringify(err));
return [];
}
}
/**
* 抓取详情 + 简介 + 海报(`/api/v1/movies/douban?id=<豆瓣id>` 或 `?q=<片名>`)。
*
* 🔴 与网页端 `apply()` 的契约**逐条对齐**:
* · `summary` 是**服务端拼好的**简介全文(含导演/主演/年份/类型/片长/国家/评分
* + 剧情原文)—— 端上不自己拼,否则两边写进库的文本会不一样;
* · `poster_key` 是**已经落盘**的海报存储键(服务端走 poster 槽位管线裁成 2:3),
* 端上保存记录时把它当 `poster_key` 一起提交即可,不需要自己再传文件。
*
* ⚠️ 只填空白的(与网页端同一语义):调用方拿到结果后,`info` 已有内容就不覆盖。
*
* @returns null = 失败(原因见 `lastError`)
*/
async doubanFetch(token: string, doubanId: string, q: string): Promise<FbDoubanFetch | null> {
if (token === '') {
this.lastError = '还没登录';
return null;
}
const query: Record<string, string> = {};
if (doubanId !== '') {
query['id'] = doubanId;
} else if (q.trim() !== '') {
query['q'] = q.trim();
} else {
this.lastError = '先写个片名';
return null;
}
const r: FbApiResult = await fbApi('/api/v1/movies/douban',
{ method: 'GET', token: token, query: query });
if (!r.ok) {
this.lastError = r.error !== '' ? r.error : '抓取失败,请稍后再试';
return null;
}
try {
const o: Record<string, Object> = JSON.parse(r.data) as Record<string, Object>;
const out: FbDoubanFetch = {
title: strField(o, 'title'),
summary: strField(o, 'summary'),
posterKey: strField(o, 'poster_key'),
posterUrl: strField(o, 'poster'),
meta: o['meta'] !== undefined ? JSON.stringify(o['meta']) : ''
};
this.lastError = '';
return out;
} catch (err) {
hilog.warn(DOMAIN, TAG, '豆瓣抓取解析失败:%{public}s', JSON.stringify(err));
this.lastError = '返回内容异常';
return null;
}
}
/**
* 传一张**海报**(`POST /api/v1/movies/<id>/poster`,multipart 字段 `poster`)。
*
* 🔴 用户 2026-10-19:「**允许上传多张图片,包括海报。**」
*
* ⚠️ 与服务端的分工:裁剪(2:3)、缩放、转 JPEG 全在**服务端**做
* (`Upload::image` 的 poster 槽位)—— 端上只负责把原图字节送上去。
* 端上自己裁会让"绕过端上(改造过的客户端)"存进非 2:3 的图。
*
* @returns 空串 = 成功;否则是错误说明
*/
async uploadPoster(id: number, uri: string, token: string): Promise<string> {
return fbUploadOne('/api/v1/movies/' + id.toString() + '/poster', 'poster', uri, token);
}
/**
* 传**观影照片**(`POST /api/v1/movies/<id>/photos`,multipart 字段 `photo`)。
*
* ⚠️ 一张一张传(而不是一次拼多张 multipart):
* · 每张的成败独立 —— 一张 12MB 的原图失败,不该把已经成功的几张一起回滚;
* · 端上进度好做(传了三张就显示"3 / 5");
* · 服务端那次是**追加**语义(`Movie::addPhotos`),多次调用结果与一次相同。
*
* @returns 空串 = 成功;否则是错误说明
*/
async uploadPhoto(id: number, uri: string, token: string): Promise<string> {
return fbUploadOne('/api/v1/movies/' + id.toString() + '/photos', 'photo', uri, token);
}
/** 影评 + 评论列表(工单 #27;`GET /movies/<id>/reviews`) */
async reviews(id: number, token: string): Promise<FbMovieReviews> {
const opts: FbApiOptions = { method: 'GET', token: token };
const res: FbApiResult = await fbApi('/api/v1/movies/' + id.toString() + '/reviews', opts);
if (!res.ok) {
return { reviews: [], mine: null, count: 0, avg: 0, catalog: {} };
}
return parseReviews(res.data);
}
/** 写 / 改我的影评(工单 #27;`POST /movies/<id>/review`) */
async saveReview(id: number, token: string, rating: number, content: string,
feelings: string[]): Promise<FbApiResult> {
const body: Record<string, Object> = {};
body['rating'] = rating;
body['content'] = content;
body['feelings'] = feelings;
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
return await fbApi('/api/v1/movies/' + id.toString() + '/review', opts);
}
/** 删我的影评(工单 #27;`DELETE /movies/<id>/review`) */
async deleteReview(id: number, token: string): Promise<FbApiResult> {
const opts: FbApiOptions = { method: 'DELETE', token: token };
return await fbApi('/api/v1/movies/' + id.toString() + '/review', opts);
}
/** 评论 / 回复(工单 #27;`POST /movies/<id>/comments`) */
async addReviewComment(id: number, token: string, reviewId: number, content: string,
parentId: number): Promise<FbApiResult> {
const body: Record<string, Object> = {};
body['review_id'] = reviewId;
body['content'] = content;
body['parent_id'] = parentId;
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
return await fbApi('/api/v1/movies/' + id.toString() + '/comments', opts);
}
/** 删评论(工单 #27;`DELETE /movies/<id>/comments?comment_id=N`) */
async deleteReviewComment(id: number, token: string, commentId: number): Promise<FbApiResult> {
const q: Record<string, string> = {};
q['comment_id'] = commentId.toString();
const opts: FbApiOptions = { method: 'DELETE', token: token, query: q };
return await fbApi('/api/v1/movies/' + id.toString() + '/comments', opts);
}
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;
}
/**
* 从 JSON 对象里安全取一个字符串字段(非字符串 → 空串)。
*
* ⚠️ 与 `strOf` 的分工:`strOf` 处理"值本身可能 null/undefined",
* 这个处理"**值可能是任意类型**"(如 `meta` 是对象、`id` 是数字)。
* 豆瓣那条接口的 `meta` 是嵌套对象,硬当字符串读会崩。
*/
function strField(o: Record<string, Object>, key: string): string {
const v: Object | undefined = o[key];
return typeof v === 'string' ? v : '';
}
// ============================================================
// 观影详情的解析
// ============================================================
/** 服务端 `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
};
}
// ============================================================
// 影评与评论解析(工单 #27)
// ============================================================
/** 服务端影评行(`MovieReview::forEvent`,含 comments 树) */
interface RawMovieReview {
id?: number; person_id?: number; person_name?: string | null;
avatar_color?: string | null; rating?: number; content?: string | null;
feelings?: string[] | null; created_at?: string | null;
comments?: RawMovieCommentTree[];
}
/** 服务端评论(两级平铺:顶层 + 内嵌 replies) */
interface RawMovieCommentTree {
comment?: RawMovieComment;
/** 回复是**平铺的评论行**(不再嵌套,服务端 `attachComments` 里就是 `replies` 数组) */
replies?: RawMovieComment[];
}
interface RawMovieComment {
id?: number; person_id?: number; person_name?: string | null;
avatar_color?: string | null; content?: string | null;
reply_to_name?: string | null; created_at?: string | null;
}
/** 一条平铺评论行 → 端上形态 */
function parseOneComment(c: RawMovieComment, meId: number): FbMovieComment {
return {
id: c.id !== undefined ? c.id : 0,
name: c.person_name !== undefined && c.person_name !== null ? c.person_name : '',
color: c.avatar_color !== undefined && c.avatar_color !== null ? c.avatar_color : '#9AA0A6',
content: c.content !== undefined && c.content !== null ? c.content : '',
replyTo: c.reply_to_name !== undefined && c.reply_to_name !== null ? c.reply_to_name : '',
createdAt: c.created_at !== undefined && c.created_at !== null ? c.created_at : '',
mine: c.person_id !== undefined ? c.person_id === meId : false,
replies: [],
};
}
/** 评论树 → 端上形态(顶层一条 = 评论 + 平铺 replies) */
function parseCommentTree(nodes: RawMovieCommentTree[] | undefined, meId: number): FbMovieComment[] {
const out: FbMovieComment[] = [];
if (nodes === undefined || nodes === null) {
return out;
}
for (const node of nodes) {
const c: RawMovieComment = node.comment !== undefined ? node.comment : {};
const top: FbMovieComment = parseOneComment(c, meId);
if (node.replies !== undefined && node.replies !== null) {
const reps: FbMovieComment[] = [];
for (const r of node.replies) {
reps.push(parseOneComment(r, meId));
}
top.replies = reps;
}
out.push(top);
}
return out;
}
/** 影评行 → 端上形态 */
function parseOneReview(rw: RawMovieReview, meId: number): FbMovieReview {
const feelings: string[] = [];
if (rw.feelings !== undefined && rw.feelings !== null) {
for (const f of rw.feelings) {
if (f !== '') {
feelings.push(f);
}
}
}
const mine: boolean = rw.person_id !== undefined ? rw.person_id === meId : false;
return {
id: rw.id !== undefined ? rw.id : 0,
name: rw.person_name !== undefined && rw.person_name !== null ? rw.person_name : '',
color: rw.avatar_color !== undefined && rw.avatar_color !== null ? rw.avatar_color : '#9AA0A6',
rating: rw.rating !== undefined ? rw.rating : 0,
content: rw.content !== undefined && rw.content !== null ? rw.content : '',
feelings: feelings,
createdAt: rw.created_at !== undefined && rw.created_at !== null ? rw.created_at : '',
mine: mine,
comments: parseCommentTree(rw.comments, meId),
};
}
/** `GET /movies/<id>/reviews` 的载荷 → 端上形态 */
function parseReviews(json: string): FbMovieReviews {
const meId: number = fbAppState().session.me.id;
try {
const o: Record<string, Object> = JSON.parse(json) as Record<string, Object>;
const rows: RawMovieReview[] = (o['items'] !== undefined && o['items'] !== null)
? (o['items'] as RawMovieReview[]) : [];
const reviews: FbMovieReview[] = [];
for (const rw of rows) {
reviews.push(parseOneReview(rw, meId));
}
// mine:服务端 `MovieReview::mine`(单个对象或 null)
let mine: FbMovieReview | null = null;
const mv: Object | undefined = o['mine'];
if (mv !== undefined && mv !== null && typeof mv === 'object') {
mine = parseOneReview(mv as RawMovieReview, meId);
}
// stats + catalog
const st: Object | undefined = o['stats'];
let count: number = 0;
let avg: number = 0;
if (st !== undefined && st !== null && typeof st === 'object') {
const so = st as Record<string, Object>;
count = typeof so['count'] === 'number' ? so['count'] : 0;
avg = typeof so['avg'] === 'number' ? so['avg'] : 0;
}
const catalog: Record<string, string> = {};
const cat: Object | undefined = o['catalog'];
if (cat !== undefined && cat !== null && typeof cat === 'object') {
const co = cat as Record<string, Object>;
const keys: string[] = Object.keys(co);
for (const k of keys) {
const v: Object | undefined = co[k];
if (v !== undefined && v !== null && typeof v === 'object') {
const vo = v as Record<string, Object>;
const emoji: Object | undefined = vo['emoji'];
const label: Object | undefined = vo['label'];
catalog[k] = (typeof emoji === 'string' ? emoji : '') +
(typeof label === 'string' ? ' ' + label : '');
}
}
}
return { reviews: reviews, mine: mine, count: count, avg: avg, catalog: catalog };
} catch (err) {
return { reviews: [], mine: null, count: 0, avg: 0, catalog: {} };
}
}