/** * 家书 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 { // ---- ① 拉影院(先拉,供观影行的影院名反查 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 = { '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 = new Map(); 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 { 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`,写属性名在 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 { 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 { 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 { return this.db.scalar("SELECT COUNT(*) FROM events WHERE kind = 'movie' AND deleted_at IS NULL"); } /** 读同步水位线 */ /** * 影院列表(**从本地库读**)。 * * 🔴 为什么从库里读、而不是现拉 `/api/v1/cinemas`: * 影院表本来就在观影同步里一起落库了(`events` + `cinemas` 同一批), * 现拉等于"已经有的数据再要一次"。 * ⚠️ 但如果本地是空的(没同步过),调用方应自己兜底 —— 这里不替你决定。 */ async cinemaList(): Promise { 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 { 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 { 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 }; }