0%

EspoCRM 与 Outlook 循环会议同步实践:窗口化展开 + 系列重建方案

前提知识:阅读本文前,假设您已了解 Microsoft Graph API 的基础概念,并熟悉 EspoCRM 的 Outlook Integration 扩展。

TL;DR (太长不看版)

  • 增量同步不可信:对于循环会议,Delta Sync 经常只返回 Master 变更,必须手动展开实例。
  • 窗口化是唯一解:不要试图同步无限的未来,只维护 [-7d, +90d] 的滑动窗口。
  • iCalUId 有坑:同一个系列的所有实例共享同一个 iCalUId,严禁直接用作唯一键去重。
  • Quota 必须设:Graph API 有严格的 429 限制,必须限制每次 Job 处理的实例数量。

在 CRM 业务系统与 Outlook/Google 托管系统的跨系统同步中,“循环会议”(Recurring Meetings)往往是最大的拦路虎。很多系统选择直接忽略它,或者只同步第一条。

但对于业务人员,周会、月度汇报是日程中最重要的一部分。如果 CRM 里看不到这些会议,日历集成的价值就打了一半折扣。本文将分享我们在 EspoCRM 中实现 Outlook 循环会议双向同步的填坑之路。

为什么循环会议这么难?

Microsoft Graph 的数据模型

在 Microsoft Graph API 中,循环会议并不是简单的“一条记录”。它被拆分为三种类型:

  1. seriesMaster:循环系列的“母体”或“模板”。它定义了规则(例如:每周四下午 2 点,无限循环),但它本身通常不作为一个具体的时间占用显示在日历上。
  2. occurrence:循环系列中的某一次具体实例(例如:2026年1月8日的周会)。它是根据 Master 动态计算出来的“虚拟”对象。
  3. exception:当某一次实例被修改(比如改了时间、地点或主题)后,它就变成了 Exception,物理上独立存储。

同步的陷阱:为何标准 Delta Sync 会失效?

当我们天真地使用 Graph API 的 Delta 接口(增量同步)时,通常会遭遇“静默失败”:

  • “隐身”的实例:你修改了系列的主规则(比如从“每周一”改为“每周二”),Delta 接口只会告诉你 seriesMaster 变了。它不会把未来半年的 24 个 occurrence 变更事件推送给你。如果你只依赖 Delta 数据更新数据库,你的 CRM 日历里,会议还停留在周一。
  • 无限数据的黑洞:如果你试图展开一个 “No End Date”(无限循环)的会议,你会得到无限的数据,瞬间撑爆数据库。
  • iCalUId 的诱惑与背叛:这可能是最大的坑。很多开发者会惯性认为 iCalUId 是全球唯一的,但在 RFC 5545 标准里,同一个系列的所有实例共享同一个 iCalUId!EspoCRM 默认逻辑喜欢用 iCalUId 来做去重(Fallback),这会导致所有周会都被合并成同一条记录——灾难。

常见失败模式

在实际工程中,如果你看到以下现象,通常就是循环逻辑出了问题:

  • 实例缺失:只处理了“主档”但没有展开;或者系列大改后没有触发重建。
  • 实例重复:错误地使用了 iCalUId 作为唯一键;或者重建时没有先清理旧数据。
  • 删除不同步:用户在 Outlook 删除了整个系列,CRM 里却只删除了 Master,留下一堆“孤儿”实例。
  • 时区错位:按 UTC 展开导致本地时段漂移;All-day 事件在跨时区时出现 +/- 1 天的误差。

解决方案:窗口化展开 + 系列重建

为了解决上述问题,我们制定了**“窗口化(Windowed)”“系列重建(Series Resync)”**的策略。

核心原则:窗口化 (Windowed)

我们无法也不应该同步“无限”的未来。

“窗口化”的核心思想是:定义一个“关注窗口”(例如 [Today - 7天, Today + 90天])。

  • 我们只关心落在这个窗口内的时间片。
  • 每天夜间任务会“滑动”这个窗口,把新进入窗口期(第 91 天)的会议实例实例化存入数据库。
  • 对于窗口外的历史数据,我们保持原样或归档;对于太远的未来,暂不处理。

这样,无论会议是“未来 10 年”还是“无限循环”,对系统的负载都是可控的(常数级)。

直观理解:窗口滑动

为了防止把“窗口”误解为 UI 界面,我们可以这样想象:

1
2
3
4
5
6
7
8
9
10
11
12
13
假设:
今天是 1月1日,窗口范围是 [1月1日, 4月1日] (+90天)。
系统会实例化:
- 1月1日 会议
- 1月8日 会议
...
- 3月26日 会议

等到 2月1日,系统自动“滑动”窗口:
新窗口范围是 [2月1日, 5月1日]。
系统会自动:
1. 忽略 1月份的历史会议(已归档或保持现状)。
2. 补齐 4月1日 至 5月1日 新进入范围的会议实例。

架构设计

flowchart TD
    Start["开始同步"] --> GetDelta["Graph API: Delta Sync"]
    GetDelta --> Loop{"遍历变更项"}

    Loop -- "Type=seriesMaster" --> MarkResync["标记为'待重建'"]
    Loop -- "Type=occurrence" --> SyncItem["同步单个实例 (禁用 iCalUId Fallback)"]
    Loop -- "Type=singleInstance" --> SyncNormal["标准同步逻辑"]

    MarkResync --> TriggerResync["触发系列重建流程"]

    subgraph SeriesResync ["系列重建 (Series Resync)"]
        Fetch["Graph API: List Instances (Windowed)"]
        Upsert["批量更新/创建实例"]
        Cleanup["清理窗口内已消失的实例"]
    end

完整同步流程

核心数据模型

不同系统的物理表结构虽有差异,但“系列=配置、实例=事实”的二元模型是通用的。在 EspoCRM 的工程实践中,我们通过扩展实体来桥接这一模型。

以 Outlook 集成为例,我们需要一个桥接实体 OutlookCalendarEvent 来存储外部状态,并与内部实体 Meeting (Internal Event) 建立映射。

erDiagram
  OUTLOOK_CALENDAR_USER ||--o{ OUTLOOK_CALENDAR_SERIES : "tracks"
  OUTLOOK_CALENDAR_USER ||--o{ OUTLOOK_CALENDAR_EVENT : "owns"
  INTERNAL_EVENT ||--o{ OUTLOOK_CALENDAR_EVENT : "linked-by"

  OUTLOOK_CALENDAR_USER {
    string id PK
    string deltaToken
  }

  OUTLOOK_CALENDAR_SERIES {
    string id PK
    string seriesMasterEventId
    boolean resyncRequested
  }

  OUTLOOK_CALENDAR_EVENT {
    string id PK
    string eventId "Outlook ID (Immutable)"
    string iCalUId "Shared by Series"
    string eventType "occurrence/master/exception"
    string seriesMasterEventId
    string otherMeta "Other Sync Meta"
  }
  • OUTLOOK_CALENDAR_SERIES: 对应 seriesMaster,只负责记录系列的元数据(如 Sync State)。
  • OUTLOOK_CALENDAR_EVENT: 对应 occurrenceexception,存储具体的会议实例信息,并通过 eventId 与 Outlook 严格绑定。
  • INTERNAL_EVENT: EspoCRM 原生的 Meeting 实体,用户在界面上实际看到的数据。

⚠️ 警惕 iCalUId 的陷阱

WARNING / 警告: 这是 99% 的开发者都会踩的坑,请务必反复阅读本节!

RFC 5545 标准规定: 对于 Recurring Event,整个系列的所有实例(Occurrence)共享同一个 UID (iCalUId)。

EspoCRM 的默认逻辑通常是:如果用 (calendarId, eventId) 找不到记录,就尝试用 iCalUId 兜底查找。这对于普通会议是很好的容错,但对于循环会议是致命的。这会导致你同步下来的 10 个周会,全部被关联到了数据库里的同一条记录上。

修正逻辑

1
2
3
4
5
6
7
8
9
// 伪代码逻辑
if ($eventType === 'occurrence' || $eventType === 'exception') {
// 严禁使用 iCalUId 查找旧记录!
// 必须严格匹配 eventId (Outlook ID)
$meeting = $this->findByEventId($eventId);
} else {
// 普通会议可以使用 iCalUId 作为兜底
$meeting = $this->findByEventId($eventId) ?? $this->findByICalUId($iCalUId);
}

系列重建 (Series Resync)

当检测到 Master 变更时,我们不直接处理 Master,而是触发一个子任务:

  1. 调用 Instances 接口/me/events/{masterId}/instances?startDateTime=...&endDateTime=...
  2. 设定窗口:通常取 [今天 - 7天, 今天 + 90天]
  3. 全量比对
    • API 返回了但 DB 没有 -> 创建
    • API 返回了且 DB 也有 -> 更新
    • DB 有但 API 没返回(且在窗口内) -> 删除(说明该次实例被取消了,或者规则变更导致该日期不再有会议)。

这一步保证了无论 Master 规则怎么变,CRM 里的实例永远与 Outlook/Google 的视图保持一致。

解决“冷启动”问题:状态机驱动的初始化

在实际运行中,如果仅依赖 Delta Sync 触发系列重建,会遇到“冷启动”问题:存量用户的历史会议无法同步。

背景知识:Delta Sync 的局限性

微软 Graph API 的 Delta Sync 是“变化驱动”的,而非“存在驱动”。它只返回在指定时间窗口内发生了变更的数据。

  • 如果你修改了一个系列的主题,Delta 会返回一条 seriesMaster 变更记录 -> 触发我们的重建逻辑。
  • 但如果一个会议是 3 年前创建的,且最近没有任何修改,那么 Delta 接口会认为它“岁月静好”,直接跳过,不会返回任何数据。
  • 这就导致了“静默缺失”:系统认为同步完成了,但日历上空空如也,只有最近新建的会议被同步了。

为此,我们引入了**“初始化状态机”**机制,将同步流程分为“初始化(Bootstrap)”和“增量(Incremental)”两个阶段:

  1. 引入状态位:在用户日历配置中增加 bootstrapStatus 字段 (Pending / Completed)。
  2. 强制扫描:每次同步任务启动时,先检查该状态。
    • 如果是 Pending:忽略 Delta Token,直接执行 [-7d, +90d] 的全量窗口扫描,将所有发现的 Series Master 纳入管理,并在完成后将状态置为 Completed
    • 如果是 Completed:才进入常规的 Delta Sync 流程。

这个机制确保了无论会议是 5 年前创建的还是昨天创建的,都能在首次同步时被正确捕获,彻底解决了“冷启动”问题。

其他系统对比:Google Calendar 的做法

Google Calendar API 在这方面表现得更为“友好”一些,但也存在类似挑战:

  • Google Calendar: 支持 syncToken (类似 Delta Token)。当 Token 失效或首次同步(不带 Token)时,Google 默认行为是执行一次 Full Sync(全量列表),会返回所有活跃的事件,包括存量的循环会议。
    • 优势:冷启动时不容易漏数据,因为它倾向于“先给你全量,再给你增量”。
    • 劣势:如果用户日历数据量巨大,首次全量同步会非常慢且容易超时,依然需要分页和窗口控制。
  • Outlook (Graph API): 设计理念更偏向“按需获取”。即使不带 Token,calendarView/delta 也主要关注“视图内的变化”。如果不显式地进行一次“无 Token 的全量 Windowed List”,很容易只拿到“最近活跃”的数据。

因此,“显式的初始化扫描”(Explicit Bootstrap Scan)是跨日历系统同步中通用的最佳实践,不仅适用于 Outlook,对于保证 Google Calendar 数据的一致性也同样重要。

3.5 完整流程时序图

sequenceDiagram
  participant S as Scheduler/Job
  participant SS as SyncService
  participant DB as Database
  participant P as Provider(Outlook/Google)
  participant W as WindowExpander

  S->>SS: Trigger user sync
  SS->>DB: Check bootstrapStatus
  
  alt Status is Pending (Cold Start)
    SS->>W: Force Windowed Scan (-7d..+90d)
    W->>P: List all instances in window
    W->>DB: Initialize Series & Instances
    SS->>DB: Update bootstrapStatus = Completed
  else Status is Completed (Incremental)
    SS->>P: Fetch delta (token/page)
    alt Series master changed
      SS->>W: Mark series for rebuild
      W->>P: List instances (window: -7d .. +90d)
      W->>DB: Upsert created/updated
      W->>DB: Delete window-missing
    else Occurrence/Exception changed
      SS->>DB: Upsert occurrence (no iCalUId fallback)
    end
  end
  SS-->>S: Report metrics

Outlook vs Google:核心差异对比

虽然原理相似,但 Google Calendar 和 Microsoft Outlook 在实现细节上有显著差异。

特性 Microsoft Graph (Outlook) Google Calendar API
循环模型 Master + Exception 分离模型
Master 是一条记录,Exception 是独立记录。Occurrence 是虚拟计算的。
Event + Recurrence
支持 singleEvents=true 参数,API 直接帮你展开成扁平的实例列表。
增量同步 (Delta) Master 变更通常只返回 Master
需要客户端自己去 fetch instances。
可选择
如果 singleEvents=true,Master 变更会直接返回所有受影响的实例变更。
ID 关联 seriesMasterId 关联实例 recurringEventId 关联原事件
删除逻辑 物理删除或即时状态变更 经常通过 status: cancelled 标记逻辑删除
复杂度 ⭐⭐⭐⭐⭐ (需手动计算/展开) ⭐⭐⭐ (API 可帮忙展开)

生产级工程实践

配额与流控 (Quota)

循环会议会产生大量记录。如果一个用户有 10 个周会,同步未来 3 个月,瞬间就会产生 120 条记录。

  • Max Instances Per Run:每次 Job 最多处理 200 个实例,超出的留到下次。这不仅是性能问题,更是防止触发微软 Graph API 的 429 (Too Many Requests) 限制的关键。
  • Max Series Resync:每次 Job 最多重建 5 个系列,避免突发流量压垮系统。

令牌失效与分页

Graph API 的 Delta Token 是有有效期的。

  • Token 失效:必须具备“捕获失效异常 -> 自动切换到全量同步”的兜底机制。
  • 分页处理:必须完整迭代完所有 nextLink 后,才能保存最后的 deltaLink

时区与 DST 的噩梦

  • 存储:建议同时存储“本地时段”+“时区 ID”。
  • DST:夏令时切换会导致 UTC 时间平移,如果只存 UTC,可能会导致会议在本地时间显示错位(比如差 1 小时)。在展开实例时,务必使用正确的时区库(如 PHP 的 DateTimeZone)。
  • 全天事件 (All-day Events):全天事件通常没有时区概念(或者说是“浮动时间”),在跨系统同步时,应忽略时区偏移,严格按照 YYYY-MM-DD 处理。否则很容易因为时区转换导致事件变成跨越两天的事件(例如从 00:00 - 24:00 变成前一天的 23:00 到当天的 23:00)。

测试清单

上线前,请务必覆盖以下场景:

  1. DST 跨越:创建一个跨越夏令时切换日(如 3 月或 11 月)的周会,检查时间是否正确。
  2. 例外处理:修改某一个实例的时间,然后修改整个系列的主题,检查该实例是否保持了特定的时间修改。
  3. 删除测试:在 Outlook 删除未来一个实例,检查 CRM 是否同步删除;删除整个系列,检查 CRM 是否清理干净。
  4. 长周期:创建一个每月的会议,持续 2 年,检查窗口化逻辑是否只同步了最近 3 个月。

总结与最佳实践

Outlook/Gmail 循环会议同步的核心在于**“承认差异,中间转换”**。

  1. 不要把 “增量=事实”当假设:对于循环会议,增量只是一个信号,必须配合 窗口化回查
  2. 统一语义:在内部模型中,把 Series 当作配置,把 Occurrence 当作事实。
  3. 防守性编程:iCalUId 只能信一半,Quota 必须设上限,日志必须可追溯。

通过“窗口化展开 + 系列重建”的方案,我们成功在 EspoCRM 中解决了这一难题,为用户提供了稳定一致的日历视图。希望这套方案对你的工程实践也有所启发。

相关资源