2026-10-18 03:35:39 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 家书 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';
|
2026-10-22 10:15:03 +08:00
|
|
|
|
import { FbApiOptions, FbApiResult, fbApi } from './FbHttp';
|
|
|
|
|
|
import { fbUploadOne } from './FbUpload';
|
2026-10-19 15:27:31 +08:00
|
|
|
|
import { FB_TOKEN_STORE } from './FbTokenStore';
|
2026-10-28 22:29:55 +08:00
|
|
|
|
import { fbAppState } from '../common/Store';
|
2026-10-18 03:35:39 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 15:27:31 +08:00
|
|
|
|
/* ===================== 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-20 07:35:37 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 「记一场」表单填出来的草稿(→ `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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 15:27:31 +08:00
|
|
|
|
/** `/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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 03:35:39 +08:00
|
|
|
|
/** 观影详情(点卡片后展示) */
|
|
|
|
|
|
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[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 22:29:55 +08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 影评与评论(工单 #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>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 03:35:39 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 观影仓库。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 与 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();
|
2026-10-18 16:01:41 +08:00
|
|
|
|
|
|
|
|
|
|
/* 🔴 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');
|
|
|
|
|
|
|
2026-10-18 03:35:39 +08:00
|
|
|
|
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-20 07:35:37 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* **新建一场观影**(用户 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 = '';
|
|
|
|
|
|
|
2026-10-19 15:27:31 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 新建 / 修改一家影院(**直接打接口,不等同步**)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 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 [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-20 07:35:37 +08:00
|
|
|
|
/* ============================================================
|
|
|
|
|
|
🔴 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> {
|
2026-10-22 10:15:03 +08:00
|
|
|
|
return fbUploadOne('/api/v1/movies/' + id.toString() + '/poster', 'poster', uri, token);
|
2026-10-20 07:35:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 传**观影照片**(`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> {
|
2026-10-22 10:15:03 +08:00
|
|
|
|
return fbUploadOne('/api/v1/movies/' + id.toString() + '/photos', 'photo', uri, token);
|
2026-10-20 07:35:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 22:29:55 +08:00
|
|
|
|
/** 影评 + 评论列表(工单 #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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 03:35:39 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-10-20 07:35:37 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 从 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 : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 03:35:39 +08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 观影详情的解析
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
/** 服务端 `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
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-10-28 22:29:55 +08:00
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 影评与评论解析(工单 #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: {} };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|