HarmonyOS 7 Calendar+rrule:跨时区轮班日程DST展开与批量回滚
十月底,客服团队把一份洛杉矶轮班计划同步到系统日历。页面预览一直显示上午 9:30,写入后却有三条变成了上午 8:30。更麻烦的是,运营同事点了两次“写入系统日历”,同一班次又出现两份。日志里没有异常,Calendar Kit 也正常返回了事件 ID。
这个问题看起来像时区格式化,实际同时夹着三件事:rrule 展开的是重复规则,系统日历保存的是绝对时间;夏令时切换会改变当地时间与 UTC 的偏移;Calendar Kit 的多次创建不是一个天然事务。只修其中一层,日程仍可能错一小时、重复或留下半批数据。

这次 Demo 叫 ShiftPulse,页面是 ScheduleCommitPage,任务号固定为 CAL-2010。输入规则为 FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=12,时区 America/Los_Angeles,首个班次是 2026-10-26 09:30,提醒提前 15 分钟。验收结果是展开 12 条、命中重复 1 条、新建 11 条、最终可见 12 条,漂移 0、回滚 0,状态 COMMITTED,提交耗时 186 ms。
一、我先把“日历时间”拆成三种值
最初代码直接把 Date 当成唯一时间。它在开发机上能跑,是因为开发机与测试数据碰巧处在同一时区。换到洛杉矶规则后,Date 里只有时间戳,没有“这条轮班必须保持当地 9:30”的业务意图。
现在模型明确保留三个层次:wallClock 是用户写下的 09:30;zoneId 是 IANA 时区;epochMs 是提交给系统日历的绝对时间。界面修改的是前两者,只有展开阶段才能计算 epochMs。这样设备时区切换、页面重建或后台恢复都不会重新解释业务时间。
第一段代码解决规则输入被过早转成时间戳的问题。rrule 负责生成日期序列,TimeZoneResolver 负责把每个当地日期与 09:30 合成为对应的 UTC 时间。
// ShiftRuleExpander.ets
import { rrulestr } from 'rrule'
export interface ShiftPlan {
taskId: string
rule: string
zoneId: string
wallClock: string
durationMin: number
}
export async function expandShiftRule(
plan: ShiftPlan,
resolver: TimeZoneResolver
): Promise<ShiftInstance[]> {
const localDates = rrulestr(plan.rule, {
dtstart: new Date('2026-10-26T00:00:00Z')
}).all()
return localDates.map((day, index) => {
const localDate = formatDateOnly(day)
const startMs = resolver.wallClockToEpoch(localDate, plan.wallClock, plan.zoneId)
return {
index,
localDate,
startMs,
endMs: startMs + plan.durationMin * 60_000,
zoneId: plan.zoneId
}
})
}
这里刻意没有使用字符串拼接再 new Date。不同运行环境对无偏移日期字符串的解释可能不同,而 DST 切换当天还会出现重复或不存在的当地时间。resolver 对非法时刻返回明确错误;对重复时刻按计划策略选择较早偏移,并把选择写进预演结果。CAL-2010 不落在模糊区间,但这个分支必须存在。
二、DST 不是整体加减一小时
America/Los_Angeles 在 2026 年 11 月 1 日结束夏令时。10 月 26 日的 09:30 对应 16:30Z,11 月 2 日的 09:30 对应 17:30Z。当地钟面没有变化,UTC 时间却变化了。如果先按固定毫秒间隔生成十二条,再套一个时区标签,后面的班次必然整体漂移。
ScheduleCommitPage 的“预演 12 次”不会写日历,只展示当地时间、UTC 时间、偏移量和指纹。测试人员可以在同一列表中看到 -07:00 到 -08:00 的切换。预演还会检查结束时间是否跨越下一个偏移边界,避免开始时间正确、结束时间却仍用旧偏移的情况。
项目目录把规则展开、时区换算、日历网关与提交账本分开:page 只持有页面状态;service/ShiftRuleExpander.ets 生成实例;service/CalendarGateway.ets 封装 Calendar Kit;store/CommitJournal.ets 记录本批创建的事件 ID。页面退出不负责删除系统事件,它只取消未开始的预演并解除账本订阅。
三、重复判断不能只比标题和开始时间
旧版用“标题 + startMs”查重。运营修改地点后重试,查不到旧事件;换设备时区后,格式化字符串不同,也会重复。现在每个实例都生成业务指纹,内容包括计划 ID、当地日期、当地开始时间、时区、时长和规则版本。标题、备注、颜色这些展示字段不进入指纹。
第二段代码解决同一班次多次提交的问题。canonicalKey 顺序固定,摘要只用于标识,不承载隐私信息;规则版本变化时会产生新指纹,由差异页让用户决定更新还是并存。
// EventFingerprint.ets
export async function fingerprintOf(
plan: ShiftPlan,
item: ShiftInstance
): Promise<string> {
const canonicalKey = [
'shiftpulse-v2',
plan.taskId,
item.localDate,
plan.wallClock,
plan.zoneId,
String(plan.durationMin)
].join('|')
return Digest.sha256(canonicalKey)
}
export function eventNote(fingerprint: string): string {
return 'ShiftPulse#' + fingerprint.slice(0, 16)
}
指纹放在事件备注的受控标记中,查询结果先做前缀过滤,再核对完整业务字段。不能只相信短摘要,也不能扫描用户所有日历。网关只访问应用自己创建的 ShiftPulse 账户;权限被撤回时立即进入 PERMISSION_REQUIRED,不复用旧查询结果。
四、批量写入必须有自己的补偿账本
Calendar Kit 提供事件创建、更新、查询和删除能力,但一次循环创建 11 条并不等于数据库事务。第七条失败时,前六条已经真实出现在系统日历。最早的实现 catch 后只弹“同步失败”,用户下一次重试就同时遇到旧半批与新批次。
第三段代码在提交前冻结差异集,只创建 missing 项,并将每个成功返回的事件 ID 立即写入本批账本。后续失败时只回滚本批刚创建的 ID,不会删除先前存在的那一条重复事件。
// CalendarCommitService.ets
export async function commitCalendarBatch(
taskId: string,
missing: PreparedEvent[],
gateway: CalendarGateway,
journal: CommitJournal
): Promise<CommitResult> {
const generation = await journal.begin(taskId, missing.length)
try {
for (const item of missing) {
const eventId = await gateway.createEvent(item)
await journal.recordCreated(generation, eventId, item.fingerprint)
}
await journal.commit(generation)
return { state: 'COMMITTED', created: missing.length, rollback: 0 }
} catch (error) {
const ids = await journal.createdIds(generation)
await gateway.deleteEvents(ids)
await journal.markRolledBack(generation)
throw error
}
}
账本的写入顺序不能反过来。系统事件创建成功后必须先持久化 eventId,再进入下一条;否则进程恰好被杀,恢复时不知道该删除谁。删除失败的 ID 会进入 RECOVERY_REQUIRED,页面展示剩余数量,不能用一个通用 Toast 把风险藏掉。

DevEco Studio 图中,左侧是 ShiftPulse 工程目录,中间显示 commitCalendarBatch 与指纹逻辑,右侧模拟器固定展示 CAL-2010 的预演与提交状态,底部 HiLog 输出:expanded=12 duplicate=1 missing=11,以及 state=COMMITTED visible=12 drift=0 rollback=0 elapsed=186ms。
五、页面重建时只恢复账本,不重放点击
ScheduleCommitPage 的按钮点击会产生 generation。预演完成后,状态从 DRAFT 进入 EXPANDED,再经过 DIFFED 才允许提交。折叠展开、语言切换或进程恢复时,页面读取当前 generation 的快照,不会因为 build 重跑 expandShiftRule,更不会自动再次调用写入。
第四段代码处理页面的重复点击和离场释放。commitPromise 让同一代次只存在一个提交;aboutToDisappear 只取消尚未进入 Calendar Kit 的计算,并移除订阅。已经开始的提交由 service 按账本完成或补偿。
// ScheduleCommitPage.ets
private commitPromise?: Promise<void>
private unsubscribe?: () => void
async commitOnce(): Promise<void> {
if (this.commitPromise) return this.commitPromise
const generation = this.model.generation
this.commitPromise = this.model.commit(generation)
.finally(() => { this.commitPromise = undefined })
return this.commitPromise
}
aboutToDisappear(): void {
this.model.cancelPreview()
this.unsubscribe?.()
this.unsubscribe = undefined
}
这里不在离场时粗暴中断系统写入,因为中断点可能正好处于“事件已创建、eventId 还没落账”。真正安全的停止点由提交服务控制。重复进入页面会订阅同一任务快照,按钮根据 COMMITTING 或 COMMITTED 状态禁用。
六、结果页必须解释那一次偏移切换
最终手机页把最关键的证据放在一起:规则、时区、首个当地时间、DST 边界前后的 UTC 对照、12 次展开结果、1 条重复、11 条新建和 0 条漂移。状态轨迹是 DRAFT → EXPANDED → DIFFED → COMMITTED,而不是只显示一个绿色“成功”。

按钮“预演 12 次”可以反复执行,但不会触碰系统日历;“写入系统日历”只对当前冻结差异有效。测试脚本还覆盖权限撤回、创建第七条失败、回滚删除失败、进程在 eventId 落账后退出四种路径。任何路径都能从 CommitJournal 重建真实状态。
七、这套方案的边界
rrule 负责规则语义,不替代时区数据库;Calendar Kit 负责系统事件,不替代应用事务。规则数量需要上限,本例最多展开 64 次,防止无 COUNT 的规则把页面拖死。账户、权限与事件字段应以目标 API 版本为准,网关层必须保留兼容适配空间。
186 ms 是这次测试机写入 11 条事件的观测值,不是性能承诺。更重要的验收指标是 visible=12、drift=0、rollback=0:用户看到的十二个班次都保持当地 9:30,重复点击没有产生第十三条,DST 切换也没有偷偷改动业务时间。
这次修复之后,我不再把“日历同步成功”等同于接口没有报错。真正的完成条件是规则解释一致、实例身份稳定、部分失败可撤销、恢复后不重放。跨时区日程最难的不是算出一个时间戳,而是让它在几周后仍然代表用户当初写下的那个时间。
更多推荐



所有评论(0)