0%

EspoCRM定制第2篇: 外部集成——Outlook双向同步实战

适用版本:EspoCRM 9.2.2+

外部集成的难点不是”调通 API”,而是”长期稳定运行”。

TL;DR

  • OAuth 2.0 实现 EspoCRM 与外部系统的安全连接
  • 增量同步用 deltaLink/skipToken 机制,避免全量拉取
  • Token 失效有恢复策略,同步失败不中断服务
  • 关键判断打英文日志,保证线上可定位

1. 集成场景概述

1.1 常见集成类型

集成类型 典型场景 同步方向
日历同步 Outlook/Google Calendar 双向
联系人同步 Outlook/Google Contacts 双向
邮件集成 SMTP/IMAP 单向(到 EspoCRM)
数据同步 ERP/财务系统 双向
Webhook 第三方通知 单向(到 EspoCRM)

1.2 日历同步的核心挑战

  • 增量同步:只拉取变更数据,避免全量拉取的性能开销
  • Token 管理:访问令牌会过期,需要自动刷新
  • 错误恢复:网络中断、Token 失效后的恢复机制
  • 数据一致性:双向同步时的冲突处理

2. OAuth 认证流程

2.1 OAuth 2.0 基本流程

flowchart LR
  A[用户点击连接] --> B[跳转到 Microsoft 授权页]
  B --> C[用户同意授权]
  C --> D[回调到 EspoCRM 带 code]
  D --> E[用 code 换 access_token]
  E --> F[保存 token 到 external_account]
  F --> G[开始同步数据]

2.2 EspoCRM 中的 External Account 机制

EspoCRM 提供了 external_account 实体来存储第三方系统的认证信息:

1
2
3
4
5
6
7
8
{
"id": "xxx",
"userId": "user_id",
"integration": "Outlook",
"accessToken": "<ACCESS_TOKEN>", // 访问令牌
"refreshToken": "<REFRESH_TOKEN>", // 刷新令牌
"expiresAt": "2025-01-27 10:00:00"
}

2.3 Token 刷新机制

1
2
3
4
5
6
7
8
// 伪代码
if ($token->isExpired()) {
$newToken = $this->oauthHelper->refresh($token->getRefreshToken());
$externalAccount->set('accessToken', $newToken->access_token);
$externalAccount->set('refreshToken', $newToken->refresh_token);
$externalAccount->set('expiresAt', $newToken->expires_at);
$this->entityManager->saveEntity($externalAccount);
}

3. 增量同步实现

3.1 为什么需要增量同步

全量拉取的问题:

  • 每次拉取所有事件,数据量大时性能差
  • 浪费 API 配额
  • 同步时间长,用户体验差

增量同步:只拉取有变化的记录。

3.2 Microsoft Graph API 的 Delta 机制

stateDiagram-v2
    [*] --> 初始同步
    初始同步 --> 分页中: 收到@odata.nextLink
    分页中 --> 分页中: 继续下一页
    分页中 --> 增量同步: 收到@odata.deltaLink
    增量同步 --> 增量同步: 使用deltaToken
    增量同步 --> 错误恢复: Token过期
    错误恢复 --> 初始同步: 重新开始

3.3 Delta Token 管理

响应格式

1
2
3
4
5
6
7
8
9
10
{
"@odata.context": "...",
"value": [
{ "id": "AAMkAG...", "subject": "会议1", "@removed": { "reason": "deleted" } },
{ "id": "AAMkAG...", "subject": "会议2" }
],
"@odata.nextLink": "https://graph.microsoft.com/...?skipToken=xxx",
// 或
"@odata.deltaLink": "https://graph.microsoft.com/...?deltaToken=xxx"
}

存储策略

Token 存储位置 用途
deltaToken outlook_calendar_user.delta_token 下次增量同步的起点
skipToken outlook_calendar_user.skip_token 分页继续的标记

3.4 同步逻辑骨架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public function syncCalendar(OutlookCalendarUser $calendarUser): void
{
$deltaToken = $calendarUser->get('deltaToken');
$skipToken = $calendarUser->get('skipToken');

$params = [
'start' => new \DateTime('-1 month'),
'end' => new \DateTime('+3 months'),
'deltaToken' => $deltaToken,
'skipToken' => $skipToken,
];

do {
$response = $this->graphClient->getCalendarView($params);

// 处理事件
foreach ($response->value as $item) {
if (isset($item['@removed'])) {
$this->handleDeletedEvent($item);
} else {
$this->handleEvent($item);
}
}

// 更新 token
if (isset($response['@odata.nextLink'])) {
$calendarUser->set('skipToken', $this->extractSkipToken($response));
$params['skipToken'] = $calendarUser->get('skipToken');
} elseif (isset($response['@odata.deltaLink'])) {
$calendarUser->set('deltaToken', $this->extractDeltaToken($response));
$calendarUser->set('skipToken', null);
break; // 同步完成
}

} while (isset($response['@odata.nextLink']));

$this->entityManager->saveEntity($calendarUser);
}

4. 错误处理与恢复

4.1 常见错误类型

错误 原因 恢复策略
401 Unauthorized Token 过期 用 refreshToken 刷新
410 Gone deltaToken 过期 重新开始全量同步
429 TooManyRequests API 限流 延迟重试
500+ 服务器错误 临时故障 延迟重试

4.2 错误处理流程

flowchart TD
    A[API 请求] --> B{响应状态}
    B -->|成功| C[处理事件数据]
    B -->|401| D[刷新 Token]
    B -->|410| E[清除 Token]

    D --> F[重试请求]
    E --> G[重新开始全量同步]
    B -->|429/500| H[延迟后重试]

4.3 代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$response = $this->graphClient->request($url);

if ($response->getStatusCode() === 401) {
// Token 过期,尝试刷新
$newToken = $this->refreshAccessToken($calendarUser);
if ($newToken) {
continue; // 重试
} else {
$this->log->error('Token refresh failed');
break;
}
}

if ($response->getStatusCode() === 410) {
// Delta token 过期,重新开始
$this->log->warning('Delta token expired, restarting sync');
$calendarUser->set('deltaToken', null);
$calendarUser->set('skipToken', null);
$this->syncCalendar($calendarUser); // 递归调用,使用无 token 开始
return;
}

5. 监控与排障

5.1 关键日志

1
2
3
4
5
[INFO] Outlook sync: Processing calendar {calendarName}.
[INFO] Outlook sync: Received deltaToken. Sync completed.
[INFO] Outlook sync: 3 events created, 1 updated, 2 deleted.
[WARNING] Outlook sync: Sync state generation expired, restarting.
[ERROR] Outlook sync: Failed to refresh token for user {userId}.

5.2 数据库验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 查看同步状态
SELECT
u.name,
cu.last_synced_at,
cu.delta_token IS NOT NULL as has_delta
FROM outlook_calendar_user cu
JOIN user u ON cu.user_id = u.id;

-- 查看同步统计
SELECT
user_id,
COUNT(*) as total_events,
MAX(synced_at) as last_sync
FROM outlook_calendar_event
GROUP BY user_id;

5.3 同步配置参数

1
2
3
4
5
{
"outlookCalendarSyncPeriod": "1 year",
"outlookCalendarSyncEndPeriod": "3 months",
"outlookCalendarSyncMaxPortionSize": 100
}
参数 说明
syncPeriod 向前拉取多长时间的数据
syncEndPeriod 向后拉取多长时间的数据
maxPortionSize 每页返回多少条记录

本篇总结

  • OAuth 2.0 实现 EspoCRM 与外部系统的安全连接
  • 增量同步用 deltaLink/skipToken 机制,避免全量拉取
  • Token 失效有恢复策略,同步失败不中断服务
  • 关键节点打英文日志,保证线上可定位