日程改为全量同步 + 修复大响应超时(laoyang/FamilyBook#31)

## 背景

用户 2026-10-30:「全部拉取,全量同步」。
起因:切到下个月再点某一天,端上没数据(即使服务端有)。
根因是请求范围写死成「今天-7 ~ +60」且切月不重拉 ⇒ 窗口外的日子永远空。

## 改动

### FbModuleLoad.loadModule
- `name` 参数从 `FbModuleName` 放宽到 `string`
  日程改成全量后,缓存键要带请求窗口(`calendar:1970-01-01:2100-12-31`),
  否则"旧窗口的数据"会被当成新窗口的数据显示。
- 新增可选 `timeoutMs`
  全量约 9.3MB / 28628 条,默认 15 秒在移动网络下会超时;
  超时后 `FbModuleRepo` 退回旧缓存,表现成"翻月没数据"且极难查。

### FbModuleRepo.load
- 透传 `timeoutMs` 给 `FbApi`。

### FbCalendarPage
- 请求改为全量范围(`CAL_FULL_FROM` ~ `CAL_FULL_TO`),超时放宽到 60 秒。
- 缓存键带窗口(`calendar:<from>:<to>`):某次请求失败后 `FbModuleRepo`
  会把**旧范围**的缓存 sink 回来,端上就把"只含 10~12 月的数据"当全量显示
  —— 这是该 bug 的帮凶,加窗口标识后从根上消失。
- `agendaDays()` 的 `-7 ~ +60` 现在**只决定列表显示多少天**,
  不再兼作请求范围(原来两个数必须同步的约束随全量同步作废)。
- 删除 `agendaFrom()` / `agendaTo()`(不再需要按当前日期算窗口)。

### 恢复 canEdit 的写权限判断
本轮改动曾把
    `isEventWritable(this.detailBase, this.books)`
回退成
    `isEditableEvent(this.detailBase)`
这是**工单 #31 已修过的 bug 回退**:只判 kind/id/calId 不看本的可写性,
导致只读共享日历里的日程也显示「编辑」按钮,用户改完点保存才报
「这本日历你没有写权限」。

已恢复,并与首页 `FbHomePage.ets:1517` 保持同一口径
(否则同一事件"从首页进能编辑、从日历页进不能")。

## 验证

`build_hap debug` → `BUILD SUCCESSFUL`(33 tasks)。

⚠️ **未做真机验证**。全量同步的实际耗时与内存占用需在真机上确认
(28628 条约 9.3MB,解析 150~300ms,主要成本是流量)。

## 备注

这 3 个文件的改动来自 2026-10-30 那一轮,因故未提交;
本次一并整理提交,与品牌改名(家书→岁月家)无关。
This commit is contained in:
2026-11-01 22:54:22 +08:00
parent d2129c9aab
commit b32501535e
3 changed files with 91 additions and 42 deletions
+14 -2
View File
@@ -79,10 +79,22 @@ export const FB_MODULE_PATH: Record<string, string> = {
* 这是**好事**(月视图也能直接命中缓存),但**不要**指望缓存里的范围
* 与当前视图严格一致;页面永远按自己需要的日期去索引。
*/
export async function loadModule<T>(name: FbModuleName, parse: (json: string) => T,
/**
* @param name 缓存键。**接收任意字符串**而不是只收 `FbModuleName`:
* 🔴 2026-10-30:日程改成全量同步后,缓存键要带上请求窗口
* (`calendar:1970-01-01:2100-12-31`),否则"旧窗口的数据"会被当成
* 新窗口的数据显示 —— 那正是"翻月没数据/数据不对"的根因之一。
* `FbModuleRepo.load` 本来就是 `string`,这里放宽是为了让调用方传得进来。
* @param timeoutMs 可选:本次请求超时(毫秒)。
* 🔴 2026-10-30:日程改成一次拉**全量**(约 9.3MB / 28628 条),
* 默认 15 秒在移动网络下会超时;超时后 `FbModuleRepo` 退回旧缓存,
* 表现成"翻月没数据"且极难查。⇒ 大响应必须能放宽超时。
*/
export async function loadModule<T>(name: string, parse: (json: string) => T,
sink: (data: T, fromCache: boolean) => void,
onNote?: (note: string) => void,
pathOverride?: string): Promise<void> {
pathOverride?: string,
timeoutMs?: number): Promise<void> {
const path: string = pathOverride !== undefined && pathOverride !== ''
? pathOverride
+7 -1
View File
@@ -65,8 +65,11 @@ export class FbModuleRepo {
/**
* @param name 缓存键(**不限于预定义模块**:两级接口用完整路径当键,
* 如 'ledger_detail_1')。
* @param timeoutMs 可选:本次请求超时(毫秒)。大响应(如日程全量 9.3MB)
* 必须放宽,否则 15 秒超时后会退回旧缓存,
* 表现成"翻月没数据",极难查。
*/
async load(name: string, path: string, sink: FbModuleSink): Promise<void> {
async load(name: string, path: string, sink: FbModuleSink, timeoutMs?: number): Promise<void> {
// ① 本地缓存(若还没读过本地库,`readCache` 会返回空串,不影响)
const cached: string = await this.readCache(name);
if (cached !== '') {
@@ -81,6 +84,9 @@ export class FbModuleRepo {
return;
}
const opts: FbApiOptions = { method: 'GET', token: token };
if (timeoutMs !== undefined && timeoutMs > 0) {
opts.timeoutMs = timeoutMs;
}
const res: FbApiResult = await fbApi(path, opts);
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '加载 %{public}s 失败:%{public}s', name, res.error);
@@ -51,16 +51,41 @@ const FB_CAL_VIEW_ORDER: string[] = ['month', 'week', 'agenda'];
/** 月历上「非本月」日期点的灰(hex 常量:格子数据结构里存的是字符串色,不能用 Resource) */
/**
* 列表(和月历)要覆盖的日期范围:今天 -7 ~ 今天 +60 天。
* 列表视图(agenda)要渲染的天数:今天 -7 ~ 今天 +60。
*
* 🔴 这两个数**必须**同时用于两处(改一处就得改另一处):
* ① `load()` 里发给服务端的 `from`/`to`(不传的话服务端只给本月 ⇒ 跨月缺数据);
* ② `agendaDays()` 的循环边界(页面实际渲染哪些天)。
* 分开写死就会变成"请求了 A 段、显示 B 段"——那正是本 bug 最容易复发的形态。
* ⚠️ 2026-10-30 起,这**只影响列表视图显示多少天**,不再决定请求范围。
* 请求已经改成全量(见 `CAL_FULL_FROM/TO` 与 `load()`)——
* 原来这两个数同时兼作服务端 from/to,导致"翻到窗口外的月份就没数据"。
* 现在数据全在本地,列表想显示多少天自己决定即可。
*/
const AGENDA_BACK_DAYS: number = 7;
const AGENDA_FWD_DAYS: number = 60;
/**
* 全量同步的请求范围(`load()` 用)。
*
* 🔴 2026-10-30 用户:「全部拉取,全量同步」。
* 起因:切到下个月再点某一天,端上没数据(即使服务端有)。
* 根因是范围写死成「今天-7 ~ +60」且切月不重拉 ⇒ 窗口外的日子永远空。
* ⇒ 改成一次性拉全量,端上按日期筛,翻到哪个月都有。
*
* ⚠️ 这两个值取得很宽(1970~2100),是因为服务端会按范围**展开重复日程**:
* 本账号实测 28628 条 / 9.3MB / 1.3s。宁可一次多拉,也不要再出现"翻月没数据"。
* 流量成本由 `module_cache` 兜住(缓存键带窗口标识,见 `load()`)。
*/
const CAL_FULL_FROM: string = '1970-01-01';
const CAL_FULL_TO: string = '2100-12-31';
/**
* 全量请求的超时(毫秒)。
*
* ⚠️ 默认 15 秒是给"几百 KB 的普通模块"定的。全量日历约 9.3MB,
* 移动网络下 15 秒会超时;一旦超时,`FbModuleRepo` 就退回旧缓存,
* 用户看到的是"翻月没数据"——症状和这次的 bug 一模一样,极难区分。
* 给 60 秒:宁可转圈久一点,也不要静默显示过期数据。
*/
const CAL_FULL_TIMEOUT_MS: number = 60000;
/** 月历格子(一个日期) */
interface FbCalCell {
/** 日期数字 */
@@ -363,25 +388,38 @@ export struct FbCalendarPage {
}
/**
* 拉 `/api/v1/calendar`(一次拿全量条目)。
* 拉 `/api/v1/calendar`(**全量**,不设窗口)。
*
* ⚠️ 接口没有"按日期查询"的参数(实测整本返回 200+ 条)
* ⇒ 拉一次全量、端上按日期筛;切换月份/视图**不再请求**。
* 🔴🔴 2026-10-30 用户报的真 bug + 定的方案:
* 「日程,11月12日,一条日程都没有。好像点下一个月,然后再点某一天,就没有数据了」
* 用户要求「全部拉取,全量同步」。
*
* 原设计(今天-7 ~ 今天+60,且切月不重拉)有三个硬伤:
* ① 翻到窗口外的月份(如 12/30 之后)**必然全空**,即使服务端有数据;
* ② 翻到往年同样全空;
* ③ 页面长时间开着跨天后,窗口不会自己往后移。
* ⇒ 改成**一次拉全量**(1970~2100),端上任意翻月都有数据。
*
* ⚠️ 为什么敢用这么大的范围:服务端 `Calendar::month()` 会按范围**展开重复日程**,
* 实测本账号全量是 28628 条 / 9.3MB / 1.3s(326 个重复系列展开出来的)。
* 解析本身只要 150~300ms,真正的成本是**流量**。
* ⇒ 靠缓存兜住:`module_cache` 里存一份全量,
* 下次进页面先读缓存立刻显示,同时后台刷新;
* 缓存 key 里**带窗口标识**(见 `CACHE_KEY`),
* 所以"旧窗口的数据被当成新窗口的数据显示"这种情况从根上不会发生
* (换了窗口就是另一个 key,读不到就是真的没有,不会拿旧数据糊弄)。
*
* ⚠️ 缓存键必须含 from/to:原来固定用 `'calendar'`,
* 如果某次请求失败,`FbModuleRepo` 会把**旧范围**的缓存又 sink 回来,
* 端上就把"只含 10 月~12 月的数据"当成全量显示 —— 那正是这个 bug 的帮凶。
*/
private async load(): Promise<void> {
/* 🔴🔴 v59(用户 2026-10-18):「列表这个视图,今天的数据缺一条。」
真根因:服务端 `/calendar` 的 from/to **默认只给本月**
(`date('Y-m-01')` ~ `date('Y-m-t')`),而列表视图要显示 `-7 ~ +60` 天。
跨月那一段服务端压根没返回 ⇒ 落在下月的日程(含"今天往后几天")
在列表里凭空少掉;月视图因为本来就只看本月,所以看不出来。
⇒ 这里**显式传范围**,覆盖列表要用的那一整段(前后各留够余量)。
⚠️ 不要只传"本月",也不要依赖服务端默认值 —— 列表与月视图用的
`allEvents` 是同一份,范围必须**取两者所需的并集**(否则修了列表
又让月视图翻页时缺数据)。
⚠️ 往前 7 天 / 往后 60 天这两个数与 `agendaDays()` 是**同一套口径**,
改一处必须改另一处(分散成两个魔法数字必然漂移)。 */
const range: string = '/api/v1/calendar?from=' + this.agendaFrom() + '&to=' + this.agendaTo();
await loadModule<FbCalEvent[]>('calendar', parseCalendar,
const from: string = CAL_FULL_FROM;
const to: string = CAL_FULL_TO;
const range: string = '/api/v1/calendar?from=' + from + '&to=' + to;
/* 缓存键带上窗口:窗口一变就是另一条缓存,不会读到别的窗口的旧数据 */
const cacheKey: string = 'calendar:' + from + ':' + to;
await loadModule<FbCalEvent[]>(cacheKey, parseCalendar,
(rows: FbCalEvent[]): void => {
this.allEvents = rows;
this.loading = false;
@@ -390,19 +428,10 @@ export struct FbCalendarPage {
this.syncNote = note;
this.loading = false;
},
range);
}
/** 列表/月历所需范围的起点(今天 - 7 天,`YYYY-MM-DD`) */
private agendaFrom(): string {
return this.isoOfDate(new Date(new Date().getFullYear(), new Date().getMonth(),
new Date().getDate() - AGENDA_BACK_DAYS));
}
/** 列表/月历所需范围的终点(今天 + 60 天,`YYYY-MM-DD`) */
private agendaTo(): string {
return this.isoOfDate(new Date(new Date().getFullYear(), new Date().getMonth(),
new Date().getDate() + AGENDA_FWD_DAYS));
range,
/* 全量响应约 9.3MB,默认 15 秒在移动网络下会超时;
超时后会退回旧缓存,表现成"翻月没数据"。给到 60 秒。 */
CAL_FULL_TIMEOUT_MS);
}
// ============================================================
@@ -630,7 +659,9 @@ export struct FbCalendarPage {
private agendaDays(): FbAgendaDay[] {
const out: FbAgendaDay[] = [];
const now: Date = new Date();
// ⚠️ 边界必须与 `load()` 请求的 from/to 一致(见 AGENDA_BACK_DAYS 的说明)
/* ⚠️ 2026-10-30:这里的 `-7 ~ +60` 现在**只决定列表显示多少天**,
与请求范围无关了(`load()` 已经改成拉全量)。
原来的注释要求"边界必须与 load() 的 from/to 一致",那条约束随全量同步作废。 */
for (let i = -AGENDA_BACK_DAYS; i <= AGENDA_FWD_DAYS; i++) {
const dt: Date = new Date(now.getFullYear(), now.getMonth(), now.getDate() + i);
const iso: string = this.isoOf(dt.getFullYear(), dt.getMonth() + 1, dt.getDate());
@@ -899,10 +930,10 @@ export struct FbCalendarPage {
accent: this.accent,
bookName: this.detailBase !== null ? this.detailBase.calName : '',
bookColor: this.detailBase !== null ? this.detailBase.color : '#9AA0A6',
/* 🔴 2026-10-29(工单 laoyang/FamilyBook#31):原来只判 `isEditableEvent`
(kind/id/calId),**没判写权限** ⇒ 只读共享本里的日程也给「编辑日程」,
用户改完点保存才报「这本日历你没有写权限」。改为按本的可写性判定。
本页 `books` 在页面加载时就拉了(见 `loadBooks`),不会出现空数组误判。 */
/* 🔴 工单 laoyang/FamilyBook#31:不能只判 isEditableEvent(kind/id/calId),
还要判**这本日历是否可写**。否则只读共享本里的日程也显示「编辑」,
用户改完点保存才报「这本日历你没有写权限」。
与首页 `FbHomePage` 的 canEdit 保持同一口径。 */
canEdit: this.detailBase !== null && isEventWritable(this.detailBase, this.books),
onNavigate: (): void => {
this.navigateDetail();