Files
FamilyBook_harmonyos/entry/src/main/ets/common/FbBackgroundSync.ets
T
laoyang 86de59616c 端上:#47~#55 打包 —— 行程双轨、动态类型、表单全局规范 (#53)
#48 计划/实际双轨:nodeAction(start/done/cancel),完成即转实际行程,
    实际行程不再显示状态;新增 actualStartedAt/actualEndedAt
#49 类型/出行方式/车辆 chip 改内部读状态(@Builder 值传参不刷新)
#50 类型点击立即变色、出行方式平铺、参与人显示已选头像
#51 节点类型动态加载(原硬编码5种)、地址 onPick 拿详细地址与坐标
#52 标签改主题色 Bold、必填红无星号、编辑行程标题同步
#53 表单两排版式(标签独占一行解决左右对不齐);电影弹层 layoutWeight 修滚动
#54 地址联想框加时间窗抑制(600ms 内不搜)、候选限高 180

含附件回填诊断日志(DIAG-ATTACH/FILL),待真机确认后移除
2026-11-05 12:41:04 +08:00

539 lines
25 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 FamilyBook · 后台定期同步(鸿蒙端,v83)
*
* 🔴 用户 2026-10-23 的原话:
* 「另外,要有定期同步的功能,就是家书app在后台,也能自动同步数据,
* 这样打开app,基本上数据就是最新的。同步的策略,去synccalendar找,里面有办法。」
*
* ⇒ 策略照搬 `SyncCalendar/entry/src/main/ets/common/BackgroundSyncService.ets`
* 与 `BackgroundSyncRunner.ets`,**不自己另发明一套**。它的分层调度是踩出来的:
*
* ① **前台**:无需在此处理(顶栏手动同步 + 页面各自同步已有)。
* ② **退后台瞬间**:`requestSuspendDelay` 申请**短时任务**,让"刚退后台那一次"
* 能跑完。⚠️ 必须在"前台或退后台 5 秒内"申请,否则被拒。
* ③ **长期后台**:`workScheduler` **延迟任务**,周期 2 小时、`isPersisted` 重启自动恢复。
* ④ **服务卡片定时刷新**:`updateDuration: 1`(单位 **30 分钟**)触发
* `FbFormAbility.onUpdateForm`,**不受"应用活跃分组"压制** ⇒ 这是唯一
* 能做到"约 30 分钟一次"的通道。借这个时机跑一次同步(带节流)。
*
* ⚠️ **30 分钟那条不能靠 workScheduler 实现**(本文件最大的坑,别改):
* 系统按"应用活跃分组"压制延迟任务周期 ——
* 活跃≥2h / 经常使用≥4h / 常用≥24h / 极少使用≥48h / 受限·从未使用禁止调度。
* 请求 30 分钟**不报错**,但会被压到 2 小时甚至更久 ⇒ **形同虚设**。
* 这也是为什么 `DEFERRED_CYCLE_MS` 写死 2 小时而不是"用户想要的 30 分钟"。
*
* ⚠️ `WorkSchedulerExtensionAbility` 单次回调**上限 2 分钟**,超时进程被杀。
*
* ⚠️ 短时任务配额:**单日 10 分钟、单次最长 3 分钟**(低电量 1 分钟),
* 用完必须 `cancelSuspendDelay` 归还,否则后面的"退后台同步"没配额可用。
*
* 🔴🔴 **后台进程是"另一个进程"**(WorkScheduler / Form 扩展都跑在独立进程里):
* 本文件里的 `prepare()` 必须在执行同步前把这些单例**重新初始化**一遍 ——
* 主进程里 `EntryAbility.onCreate` 做过的那些 init,在新进程里**一个都没做**。
* 漏了任何一步的表现都是"后台同步永远失败、但日志里看不出原因":
* · 漏 `FB_TOKEN_STORE.init` ⇒ 令牌空 ⇒ `syncAll` 直接判"未登录"
* · 漏 `FB_SERVER_STORE.init` ⇒ 服务器地址空 ⇒ `fbApi` 报 `no_server`
* · 漏 `FbDb.open` ⇒ 数据拉下来也存不进去(只能空跑一趟网络)
* · 漏 `FB_MODULE_TOKEN.set` ⇒ 通用缓存仓库拿不到令牌(它走注入式读取)
*
* ⚠️ 所有方法**永不抛错**(后台路径没有 UI 承担异常)。
*/
import { common } from '@kit.AbilityKit';
import { backgroundTaskManager, workScheduler } from '@kit.BackgroundTasksKit';
import { preferences } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { FB_SERVER_STORE } from '../data/FbServerStore';
import { FB_DEVICE_STORE } from '../data/FbDeviceStore';
import { FB_SESSION } from '../data/FbSession';
import { FB_KEY_STORE } from '../data/FbKeyStore';
import { FbDb } from '../data/FbDb';
import { FB_MODULE_TOKEN } from '../data/FbModuleRepo';
import { FbSyncAllResult, syncAll } from '../data/FbSyncAll';
import { FB_CAL_SETTINGS } from '../data/FbCalSettings';
import { FbCalMirror, FbMirrorResult } from '../data/FbCalMirror';
import { FbCalInbound, FbInboundResult } from '../data/FbCalInbound';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbBgSync';
/** 延迟任务的 workId(与 `module.json5` 里声明的 extension 名配套) */
const WORK_ID: number = 2001;
/** 延迟任务要拉起的扩展名(必须与 `module.json5` 的 `name` 逐字相同) */
const WORK_ABILITY: string = 'FbSyncWorkAbility';
/**
* 延迟任务周期:**2 小时**。
* 🔴 不是"用户想要的间隔",而是系统"活跃分组"的**下限** —— 写小了会被压制,
* 不报错、也不生效(详见文件头)。想更频繁只能靠服务卡片刷新(30 分钟那条)。
*/
const DEFERRED_CYCLE_MS: number = 2 * 60 * 60 * 1000;
/**
* 卡片驱动同步的最小间隔。
*
* ⚠️ 与 `form_config.json` 的 `updateDuration: 1`(30 分钟)**必须对得上**:
* 比 30 分钟还大的话,每两次卡片刷新就白白跳过一轮;
* 太小则系统刷新一有漂移就会连着跑两遍。取 25 分钟,留 5 分钟容差。
* (SyncCalendar 的取值与理由同此。)
*/
const CARD_MIN_GAP_MS: number = 25 * 60 * 1000;
/**
* 卡片进程里**没申请到短时任务**时的软超时。
*
* 🔴 官方规定 `FormExtensionAbility` 生命周期回调返回后进程**仅存续约 10 秒**,
* 超过就被回收(异步任务被硬杀半途)。这里主动在 9 秒收手,
* 让"这一次"**干净地放弃**而不是被硬杀 —— 同步是幂等的,下轮会重试。
*/
const CARD_SOFT_TIMEOUT_MS: number = 9 * 1000;
/**
* 卡片驱动同步可用的**总时长上限**。
*
* 🔴 短时任务单日总配额只有 10 分钟。若卡片每 30 分钟就把配额吃满,
* "退后台那一次同步"(`onBackground` 申请)就会没配额可用 ——
* 那是用户最需要它跑的时刻。⇒ 卡片这条路每次最多 60 秒。
*/
const CARD_MAX_TOTAL_MS: number = 60 * 1000;
/** 申请到短时任务后留出的余量:提前收手才能干净地 `cancelSuspendDelay` */
const TRANSIENT_RESERVE_MS: number = 15 * 1000;
/**
* 回前台 / 退后台补同步的**最小间隔**。
*
* 🔴 为什么必须有这道节流:`onForeground` / `onBackground` 会被**反复**触发
* (切应用、下拉通知栏、锁屏解锁、多任务来回滑),
* 每次都真跑一轮就是要用户为"切一下应用"付一次网络往返。
* 而同步是增量的、又幂等 —— 5 分钟内基本不可能有新数据。
* ⚠️ 取 5 分钟而不是 30:用户"去别的应用抄个东西再回来"是常见动作,
* 超过 5 分钟的数据变化他大概率是想看到的(卡片驱动的 30 分钟那条只保证桌面)。
*/
const RESUME_MIN_GAP_MS: number = 5 * 60 * 1000;
const STORE_NAME: string = 'fb_bg_sync';
const K_ENABLED: string = 'enabled';
const K_LAST_CARD: string = 'last_card_sync_at';
const K_LAST_ANY: string = 'last_sync_at';
/** 一次后台同步的结果(只用于日志,不做 UI) */
export class FbBgSyncResult {
/** 是否真的跑了同步(false = 未登录/未配置服务器/被节流,属正常跳过) */
ran: boolean = false;
/** 同步是否全部成功 */
ok: boolean = false;
done: number = 0;
total: number = 0;
/** 人话说明 */
note: string = '';
}
class FbBackgroundSyncImpl {
/** 当前短时任务的 requestId(0 = 无);退后台申请、回前台取消 */
private transientId: number = 0;
/* ═══════════════════════════════════════════════════════════
① 用户开关
═══════════════════════════════════════════════════════════ */
/**
* 是否开启"后台自动同步"。**默认开启**。
*
* 🔴 为什么默认开:用户要的是"打开 app 数据就是最新的",
* 默认关等于这个功能不存在。关掉是给"在意流量/电量"的人留的口子。
* ⚠️ 读不到 preferences 时返回 true(宁可多同步一次,也不要静默失效)。
*/
async isEnabled(ctx: common.Context): Promise<boolean> {
try {
const p: preferences.Preferences = await preferences.getPreferences(ctx, STORE_NAME);
const v: preferences.ValueType = await p.get(K_ENABLED, true);
return typeof v === 'boolean' ? v : true;
} catch (err) {
hilog.warn(DOMAIN, TAG, '读后台同步开关失败(按开启处理):%{public}s', JSON.stringify(err));
return true;
}
}
/**
* 设置页切换开关:开 ⇒ 注册延迟任务;关 ⇒ 取消延迟任务。
*
* ⚠️ 关掉时**顺手取消短时任务**:用户刚说了不要后台同步,
* 不该还留一个配额在用着。
*/
async setEnabled(ctx: common.Context, on: boolean): Promise<void> {
try {
const p: preferences.Preferences = await preferences.getPreferences(ctx, STORE_NAME);
await p.put(K_ENABLED, on);
await p.flush(); // 不 flush 等于没写(进程随时可能被回收)
} catch (err) {
hilog.warn(DOMAIN, TAG, '写后台同步开关失败:%{public}s', JSON.stringify(err));
}
if (on) {
this.ensureWork(ctx);
} else {
this.cancelWork(ctx);
this.cancelTransient();
}
}
/** 上次同步时间(毫秒;0 = 从没同步过)。设置页展示"上次同步"用。 */
async lastSyncAt(ctx: common.Context): Promise<number> {
return this.readNum(ctx, K_LAST_ANY);
}
/* ═══════════════════════════════════════════════════════════
② 延迟任务(长期后台)
═══════════════════════════════════════════════════════════ */
/**
* 注册(或刷新)延迟任务。**幂等**:重复调用只是让系统重排同一个 workId。
*
* ⚠️ `isPersisted: true` 是"重启后自动恢复"的开关,缺了它设备重启后
* 这个任务就永久消失(用户不会知道,只会觉得"后台不同步了")。
* ⚠️ `networkType: NETWORK_TYPE_ANY`:没网时不必浪费一次唤醒。
*/
ensureWork(ctx: common.Context): void {
/* ⚠️ 这里取包名用的是 `ctx.applicationInfo.name` —— **不是 `bundleName`**。
`ApplicationInfo` 里没有 `bundleName` 这个字段(写了会编译报
`Property 'bundleName' does not exist on type 'ApplicationInfo'`),
而 `name` 的文档原文就是「Name of the application bundle. It corresponds to
the **bundleName** field in the app.json5」⇒ 它就是包名。
另一条路 `bundleManager.getBundleInfoForSelfSync().name` 也行,
但那是**跨进程通信**(IPC,几毫秒到几十毫秒),在 onCreate 里不值得。
⚠️ `WorkInfo.bundleName` 字段本身**必须**是包名 —— 写错了 `startWork`
不报错、但永远不会回调(系统找不到这个扩展)。 */
const info: workScheduler.WorkInfo = {
workId: WORK_ID,
bundleName: ctx.applicationInfo.name,
abilityName: WORK_ABILITY,
networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY,
isRepeat: true,
isPersisted: true,
repeatCycleTime: DEFERRED_CYCLE_MS,
};
try {
workScheduler.startWork(info);
hilog.info(DOMAIN, TAG, '延迟任务已注册:%{public}d 小时兜底同步', DEFERRED_CYCLE_MS / 3600000);
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '延迟任务注册失败:%{public}s %{public}s',
(e.code !== undefined ? e.code.toString() : '-'), e.message);
}
}
/** 取消延迟任务(用户关掉后台自动同步时) */
cancelWork(ctx: common.Context): void {
const info: workScheduler.WorkInfo = {
workId: WORK_ID,
bundleName: ctx.applicationInfo.name,
abilityName: WORK_ABILITY,
networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY,
isRepeat: true,
isPersisted: true,
repeatCycleTime: DEFERRED_CYCLE_MS,
};
try {
workScheduler.stopWork(info, true);
hilog.info(DOMAIN, TAG, '延迟任务已取消');
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '延迟任务取消失败:%{public}s', e.message);
}
}
/* ═══════════════════════════════════════════════════════════
③ 短时任务(退后台那一次)
═══════════════════════════════════════════════════════════ */
/**
* 申请短时任务,返回**实际可用时长(毫秒)**;失败返回 0。
*
* ⚠️ 只能在"应用处于前台"或"`onBackground` 回调里(5 秒内)"调用,
* 其它时机会被系统拒绝(9900002 等)。被拒**不是 bug**,调用方退回软超时即可。
* ⚠️ 已有短时任务在跑时**不重复申请** —— 重复申请会白白消耗当日配额。
*/
requestTransient(reason: string): number {
if (this.transientId !== 0) {
return 0;
}
try {
const info: backgroundTaskManager.DelaySuspendInfo =
backgroundTaskManager.requestSuspendDelay(reason, (): void => {
// 超时回调:配额用尽,系统即将挂起。清状态,等下次前/退后台再申请
hilog.info(DOMAIN, TAG, '短时任务超时(配额用尽),后台同步将被挂起');
this.transientId = 0;
});
this.transientId = info.requestId;
const actual: number = info.actualDelayTime !== undefined ? info.actualDelayTime : 0;
hilog.info(DOMAIN, TAG, '短时任务已申请:剩余 %{public}d 秒', Math.round(actual / 1000));
return actual;
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '短时任务申请失败(不影响使用):%{public}s', e.message);
return 0;
}
}
/**
* 取消短时任务,**归还当日配额**。
*
* 🔴 这一步不是可选的:配额一天只有 10 分钟,跑完不还,
* 下一次"退后台同步"就会申请不到 —— 而用户只会看到"后台又不同步了"。
*/
cancelTransient(): void {
if (this.transientId === 0) {
return;
}
try {
backgroundTaskManager.cancelSuspendDelay(this.transientId);
hilog.info(DOMAIN, TAG, '短时任务已取消(归还配额)');
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '短时任务取消失败:%{public}s', e.message);
}
this.transientId = 0;
}
/**
* 回前台 / 退后台的**节流版**同步。
*
* 与 `runOnce` 的区别只有一处:距上次同步不到 `RESUME_MIN_GAP_MS` 就跳过。
* 理由见常量说明(前后台回调会被反复触发,不节流等于"切一下应用打一次网络")。
*
* ⚠️ 手动同步(顶栏那颗按钮)**不走这里** —— 用户明确点了就必须真的跑,
* 不能被节流挡掉,否则他会以为"点了没反应"。
*/
async runOnceResumed(ctx: common.Context, tag: string): Promise<void> {
try {
const last: number = await this.readNum(ctx, K_LAST_ANY);
const now: number = Date.now();
if (last > 0 && now - last < RESUME_MIN_GAP_MS) {
hilog.info(DOMAIN, TAG, '同步[%{public}s] 跳过:距上次仅 %{public}d 分钟',
tag, Math.round((now - last) / 60000));
return;
}
await this.runOnce(ctx, tag);
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '同步[%{public}s] 异常:%{public}s', tag, e.message);
}
}
/* ═══════════════════════════════════════════════════════════
④ 执行体(所有触发源共用)
═══════════════════════════════════════════════════════════ */
/**
* 在新进程里把"主进程启动时该做的事"补齐,再判断能不能同步。
*
* 🔴 后台扩展跑在**独立进程**里,`EntryAbility.onCreate` 那套初始化
* 在这里**一步都没跑过** —— 见文件头"后台进程是另一个进程"那段。
*
* @returns true = 可以同步;false = 未登录 / 未配置服务器(正常跳过)
*/
private async prepare(ctx: common.Context): Promise<boolean> {
try {
await FB_TOKEN_STORE.init(ctx);
await FB_SERVER_STORE.init(ctx);
await FB_DEVICE_STORE.init(ctx);
await FB_SESSION.init(ctx);
// 数据密钥(附件解密用)。失败了不影响"拉数据",只影响附件预览
await FB_KEY_STORE.init(ctx);
/* ⚠️ 通用缓存仓库靠注入拿令牌(它与令牌存储之间有循环 import 风险,
见 `FbModuleRepo.FbModuleToken` 的说明)⇒ 新进程必须重新注入一次,
否则 `module_cache` 那些模块永远拿不到令牌、全部 401。 */
FB_MODULE_TOKEN.set((): string => FB_TOKEN_STORE.get());
// 打开本地库:不开的话数据拉下来也存不住,白跑一趟网络
await FbDb.inst().open(ctx);
} catch (err) {
hilog.warn(DOMAIN, TAG, '后台同步的准备步骤失败:%{public}s', JSON.stringify(err));
// 继续往下走:也许只是某个非关键步骤失败(比如密钥库不支持)
}
if (FB_TOKEN_STORE.get() === '') {
hilog.info(DOMAIN, TAG, '后台同步跳过:没有令牌(未登录)');
return false;
}
if (FB_SERVER_STORE.get() === '') {
hilog.info(DOMAIN, TAG, '后台同步跳过:没配置服务器地址');
return false;
}
return true;
}
/**
* 跑一次完整同步。**永不抛错**。
*
* @param tag 触发来源(`deferred` / `transient` / `cardTick` / `foreground` / `netRecover`)
*/
/**
* 跑一次「系统日历镜像」(入站 → 出站)。
*
* 🔴 2026-11-03 新增。用户要求把日历镜像到系统日历,
* 这样小艺 / 桌面卡片 / 手表能看到日程。
*
* ⚠️ **整段吞异常**:镜像是附加能力,失败不能让同步算失败。
* (与上面「后台路径必须吞掉所有异常」同一条纪律。)
* ⚠️ 只在用户开着开关、且已授权时才跑。
*/
private async runMirror(ctx: common.Context, tag: string): Promise<void> {
try {
if (!FB_CAL_SETTINGS.getMirror()) {
return;
}
if (!await FbCalMirror.hasPermission()) {
return;
}
// ① 入站:系统日历里的改动 → 服务器(必须在出站之前)
const inb: FbInboundResult = await FbCalInbound.reconcile(ctx);
// ② 出站:最新数据 → 系统日历
const r: FbMirrorResult = await FbCalMirror.syncNow(ctx);
hilog.info(DOMAIN, TAG,
'镜像[%{public}s]:入站 改=%{public}d 删=%{public}d 增=%{public}d;'
+ '出站 增=%{public}d 更=%{public}d 删=%{public}d 失败=%{public}d',
tag, inb.updated, inb.deleted, inb.created,
r.added, r.updated, r.deleted, r.failed);
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '镜像[%{public}s] 失败(不影响同步):%{public}s', tag, e.message);
}
}
async runOnce(ctx: common.Context, tag: string): Promise<FbBgSyncResult> {
const r: FbBgSyncResult = new FbBgSyncResult();
const t0: number = Date.now();
try {
if (!(await this.isEnabled(ctx))) {
r.note = '后台自动同步已关闭';
hilog.info(DOMAIN, TAG, '后台同步[%{public}s] 跳过:用户已关闭', tag);
return r;
}
if (!(await this.prepare(ctx))) {
r.note = '未登录或未配置服务器,跳过';
return r;
}
r.ran = true;
hilog.info(DOMAIN, TAG, '后台同步[%{public}s] 开始', tag);
const res: FbSyncAllResult = await syncAll();
r.ok = res.ok;
r.done = res.done;
r.total = res.total;
r.note = res.note;
/* 🔴 2026-11-03:同步成功后接着跑「系统日历镜像」(用户要求的功能)。
⚠️ 顺序:先入站(把用户在系统日历里的改动收回服务器),再出站(写回系统日历)。
反了的话出站会把系统日历里的旧值当"期望值",覆盖掉用户的改动。
⚠️ 整段 try/catch 包住:镜像失败**绝不能**让同步算失败 ——
同步是主业,镜像是附加能力,坏了不该拖累数据对齐。 */
await this.runMirror(ctx, tag);
await this.writeNum(ctx, K_LAST_ANY, Date.now());
hilog.info(DOMAIN, TAG, '后台同步[%{public}s] 完成:%{public}d/%{public}d,耗时 %{public}d 秒',
tag, res.done, res.total, Math.round((Date.now() - t0) / 1000));
} catch (err) {
/* 🔴 后台路径**必须吞掉所有异常**:抛出去没人接(没有 UI 栈),
系统只会看到"扩展崩了",而用户那边表现为"卡片变白 / 后台不同步"。 */
const e: BusinessError = err as BusinessError;
r.note = '同步异常:' + (e.message !== undefined ? e.message : JSON.stringify(err));
hilog.warn(DOMAIN, TAG, '后台同步[%{public}s] 异常:%{public}s', tag, r.note);
}
return r;
}
/**
* ⭐ **服务卡片定时刷新驱动**的同步(0.0.8 起唯一的"约 30 分钟"通道)。
*
* 为什么是卡片:`workScheduler` 周期被活跃分组压制到 ≥2 小时(见文件头),
* 而卡片配了 `updateDuration: 1`(30 分钟)后系统会**每 30 分钟**回调一次
* `onUpdateForm`,且不受活跃分组压制(每天上限 50 次)。
*
* ⚠️ 节流:系统刷新可能漂移或重入 ⇒ 用时间戳挡一道,保证两次真实同步
* 之间至少间隔 `CARD_MIN_GAP_MS`。**先写时间戳再同步**,
* 避免执行期间卡片再次刷新导致并发重入(两个进程同时拉同一批数据)。
* ⚠️ 时间预算:优先申请短时任务把 10 秒窗口延长;申请不到就 9 秒收手。
* 两种情况下都**主动放弃**而不是被硬杀 —— 同步幂等,下轮重试即可。
*/
async runThrottledByCard(ctx: common.Context): Promise<void> {
try {
const last: number = await this.readNum(ctx, K_LAST_CARD);
const now: number = Date.now();
if (last > 0 && now - last < CARD_MIN_GAP_MS) {
hilog.info(DOMAIN, TAG, '卡片驱动同步:距上次仅 %{public}d 分钟,跳过',
Math.round((now - last) / 60000));
return;
}
await this.writeNum(ctx, K_LAST_CARD, now);
// 尝试把 10 秒窗口延长到最多 3 分钟(失败则退回 9 秒软超时)
let budget: number = CARD_SOFT_TIMEOUT_MS;
const granted: number = this.requestTransient('正在同步岁月家数据');
if (granted > 0) {
const usable: number = granted - TRANSIENT_RESERVE_MS;
budget = usable > CARD_SOFT_TIMEOUT_MS ? usable : CARD_SOFT_TIMEOUT_MS;
if (budget > CARD_MAX_TOTAL_MS) {
budget = CARD_MAX_TOTAL_MS;
}
}
hilog.info(DOMAIN, TAG, '卡片驱动同步:开始(预算 %{public}d 秒)', Math.round(budget / 1000));
try {
await this.raceWithBudget(this.runOnce(ctx, 'cardTick'), budget);
} finally {
// 无论成败都释放:① 归还配额 ② 避免超时不取消被系统强杀进程
this.cancelTransient();
}
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '卡片驱动同步异常:%{public}s', e.message);
}
}
/**
* 给一个异步任务套"时间预算":超时就让调用方继续,**不杀**那个任务。
*
* ⚠️ 用 `Promise.race` 而不是 `AbortController`:这里的任务是**一串幂等的
* 网络+落库**,中途掐断会留下"拉了一半"的中间态;让它自己在后台跑完,
* 或者被系统回收(同样幂等),都更安全。
*/
private raceWithBudget(job: Promise<FbBgSyncResult>, budgetMs: number): Promise<void> {
return new Promise<void>((resolve: () => void): void => {
let settled: boolean = false;
const fin = (): void => {
if (!settled) {
settled = true;
resolve();
}
};
job.then((): void => { fin(); }).catch((): void => { fin(); });
setTimeout((): void => {
if (!settled) {
hilog.info(DOMAIN, TAG, '卡片驱动同步:时间预算用尽,主动收手(幂等,下轮重试)');
}
fin();
}, budgetMs);
});
}
/* ---- preferences 小工具 ---- */
private async readNum(ctx: common.Context, key: string): Promise<number> {
try {
const p: preferences.Preferences = await preferences.getPreferences(ctx, STORE_NAME);
const v: preferences.ValueType = await p.get(key, 0);
return typeof v === 'number' ? v : 0;
} catch (err) {
return 0;
}
}
private async writeNum(ctx: common.Context, key: string, val: number): Promise<void> {
try {
const p: preferences.Preferences = await preferences.getPreferences(ctx, STORE_NAME);
await p.put(key, val);
await p.flush();
} catch (err) {
hilog.warn(DOMAIN, TAG, '写 %{public}s 失败:%{public}s', key, JSON.stringify(err));
}
}
}
export const FB_BG_SYNC: FbBackgroundSyncImpl = new FbBackgroundSyncImpl();