Cal.diy 日历 API v2 深度指南:连接日历、查询忙碌时间与可用性调度
【免费下载链接】cal.diyScheduling infrastructure for absolutely everyone.项目地址: https://gitcode.com/GitHub_Trending/ca/cal.diy
本文以 Calendars API Reference 为骨架,结合 Cal.diy(开源版 Cal.com 调度平台)仓库内
apps/api/v2的实际控制器、服务与端到端测试源码进行校验与扩充,帮助你掌握「列出已连接日历、查询忙碌时间(busy times)、管理连接/断开/ICS Feed、设置目标日历与选中日历」这组调度基础设施中最核心的日历集成能力。读完你将能:看懂 v2 日历相关端点的真实路由与参数形态,正确发起 OAuth 连接流程,理解 busy times 如何参与可用性计算,并能用 Webhook 与连接池化等方式写出更稳的生产级集成代码。
一、端点总览:文档描述与仓库实现的差异
参考文档给出的端点为总览如下:
| Method | Endpoint | Description |
|---|---|---|
| GET | /v2/calendars | List connected calendars |
| GET | /v2/calendars/busy-times | Get busy times |
| GET | /v2/calendars/{calendar}/check | Check calendar connection |
| POST | /v2/calendars/{calendar}/connect | Connect a calendar |
| DELETE | /v2/calendars/{calendar}/disconnect | Disconnect a calendar |
| GET | /v2/calendars/{calendar}/credentials | Get calendar credentials |
| GET | /v2/destination-calendars | List destination calendars |
| GET | /v2/selected-calendars | List selected calendars |
对照仓库源码(calendars.controller.ts、destination-calendars.controller.ts、selected-calendars.controller.ts),当前实现中端点存在若干差异,下文均以仓库代码为准:
connect实际是GET /v2/calendars/:calendar/connect,返回授权 URL 而非直接 POST;OAuth 回调走GET /v2/calendars/:calendar/save。disconnect实际是POST /v2/calendars/:calendar/disconnect,请求体携带credentialId。credentials路由为POST /v2/calendars/:calendar/credentials,当前仅对 Apple 日历生效(提交username/password)。destination-calendars控制器当前仅暴露PUT /v2/destination-calendars(更新),列表信息通过GET /v2/calendars响应体中的destinationCalendar一并返回。selected-calendars控制器当前暴露POST /v2/selected-calendars(新增)与DELETE /v2/selected-calendars(删除,Query 传credentialId/integration/externalId)。- busy-times 的真实查询参数为
timeZone(或已废弃的loggedInUsersTz)+dateFrom/dateTo+calendarsToLoad数组,而不是参考文档示例中的startTime/endTime/credentialId单值形态(细节见第三节)。
以 e2e 测试为证(calendars.controller.e2e-spec.ts)覆盖了/v2/calendars/office365/connect、/v2/calendars/google/check、/v2/calendars/busy-times、/v2/calendars等路由;selected-calendars.controller.e2e-spec.ts 与 destination-calendars.controller.e2e-spec.ts 则验证了POST /v2/selected-calendars、DELETE /v2/selected-calendars、PUT /v2/destination-calendars的读写行为。
路径参数
{calendar}的取值不是人读的 slug(google-calendar),而是平台常量中的office365、apple(见 platform/constants/apps.ts),ICS Feed 则是固定路由ics-feed。这些值下文统称「日历类型标识」。
二、列出已连接日历:GET /v2/calendars
GET /v2/calendars请求头需要携带 API Key 或 Access Token(控制器上统一使用API_KEY_OR_ACCESS_TOKEN_HEADER与ApiAuthGuard,参考 calendars.controller.ts)。
响应结构
{ "status": "success", "data": { "connectedCalendars": [ { "integration": { "type": "google_calendar", "title": "Google Calendar", "slug": "google-calendar" }, "credentialId": 123, "primary": { "externalId": "primary", "name": "john@gmail.com", "email": "john@gmail.com", "isSelected": true, "readOnly": false }, "calendars": [ { "externalId": "primary", "name": "john@gmail.com", "email": "john@gmail.com", "isSelected": true, "readOnly": false }, { "externalId": "calendar-id-2", "name": "Work Calendar", "email": "john@gmail.com", "isSelected": true, "readOnly": false } ] } ], "destinationCalendar": { "id": 1, "integration": "google_calendar", "externalId": "primary", "name": "john@gmail.com" } } }字段含义与实现依据:
data.connectedCalendars:按凭据分组的已连接日历列表;每组含integration(App 元数据,type形如google_calendar,slug形如google-calendar)、credentialId、primary(该凭据下的主日历)与calendars(该凭据可用的全部日历)。data.destinationCalendar:当前用户/事件类型级别的目标日历,即新建预约要写入的日历。见 connected-calendars.output.ts:响应数据被建模为connectedCalendars+destinationCalendar两部分,其中destinationCalendar还包含userId、eventTypeId、credentialId、delegationCredentialId、primaryEmail等可空字段。- 服务端由 calendars.service.ts 的
getCalendars(userId)实现,内部先查缓存再回源数据库,并通过CalendarsCacheService写回缓存;也就是说该接口结果会被缓存,写入侧(如连接、断开、保存 ICS)都会主动清除缓存,保证一致性。
小贴士:响应 DTO 中的类型细节
ConnectedCalendarsOutput对externalId/name/email采用@IsEmail()校验(见 connected-calendars.output.ts),因此当第三方日历 externalId 不是邮箱形态时,接口输出的归一化规则应以实际返回为准——例如 Google 的externalId为primary或日历资源 ID 时即为字符串原样。
三、忙碌时间查询:GET /v2/calendars/busy-times
忙碌时间接口负责把「已连接日历上用户不可用的时间段」拉取出来,是上层 slot 计算的核心输入。
真实 Query 参数(以仓库 DTO 为准)
定义见 busy-times.input.ts:
| Parameter | Type | Required | Description |
|---|---|---|---|
| timeZone | string | 是(与 loggedInUsersTz 二选一) | 查询忙碌时间所用的时区,例如America/New_York;会被normalizeTimezone规整 |
| loggedInUsersTz | string | 否(已废弃) | 旧版时区参数,Deprecated: Use timeZone instead |
| dateFrom | string | 是 | 查询起始日期,ISO 8601 date string,如2024-12-18 |
| dateTo | string | 是 | 查询结束日期,ISO 8601 date string,如2024-12-18 |
| calendarsToLoad | array | 是 | 需要检查的日历集合,每项为{ credentialId: number, externalId: string } |
类级别校验器@ValidateTimezoneRequired()保证timeZone与loggedInUsersTz必须提供其一,否则返回400,错误信息为"Either timeZone or loggedInUsersTz must be provided";loggedInUsersTz仅为向后兼容保留。
请求示例
GET /v2/calendars/busy-times?timeZone=America/New_York&dateFrom=2024-12-18&dateTo=2024-12-18&calendarsToLoad[0][credentialId]=135&calendarsToLoad[0][externalId]=john@gmail.com控制器在读取时优先使用timeZone,取不到再回退loggedInUsersTz,随后把参数交给calendarsService.getBusyTimes()(见 calendars.controller.ts)。e2e 测试同时覆盖了「缺时区返回 400」「传 timeZone 成功」「传 loggedInUsersTz 向后兼容成功」三种场景(calendars.controller.e2e-spec.ts)。
响应结构
{ "status": "success", "data": [ { "start": "2024-01-15T10:00:00.000Z", "end": "2024-01-15T11:00:00.000Z", "source": "google_calendar" }, { "start": "2024-01-16T14:00:00.000Z", "end": "2024-01-16T15:00:00.000Z", "source": "google_calendar" } ] }注意:响应的数据项定义在 busy-times.output.ts,官方 DTO 明确包含start(Date)、end(Date)与可空的source(string)。source为来源日历类型(如google_calendar);title等字段取决于底层 provider 返回值,不应作为稳定契约依赖。
服务端调用链
CalendarsService.getBusyTimes()(calendars.service.ts)的处理顺序:
getUniqCalendarCredentials():去重calendarsToLoad中的credentialId,按用户拉取凭据,若数量对不上抛出UnauthorizedException("These credentials do not belong to you"),防止越权查询他人的日历凭据。getCalendarsWithCredentials():把integration = credential.type拼入待查日历,再交给getBusyCalendarTimes()(platform-libraries 提供)执行各 provider 的 busy/free 查询。- 拿到
EventBusyDate数组后,用 LuxonDateTime将start/end换算到请求时区再序列化返回。
四、连接生命周期:check / connect / save / disconnect / credentials
4.1 检查连接状态:GET /v2/calendars/{calendar}/check
GET /v2/calendars/google/check路径参数{calendar}支持google、office365、apple。该路由额外受PermissionsGuard保护并要求APPS_READ权限(见 calendars.controller.ts)。内部由各 provider service 分派:
- Google →
GoogleCalendarService.check(userId)(gcal.service.ts) - Office 365 →
OutlookService.check(userId) - Apple →
AppleCalendarService.check(userId)
响应形如{ "status": "success" };若未连接或凭据失效则抛出 4xx。文档最佳实践第 1 条「操作前先 check」正对应此端点。
4.2 发起 OAuth 连接:GET /v2/calendars/{calendar}/connect
注意:参考文档写作POST .../connect,但仓库实现是GET,且只支持google与office365:
GET /v2/calendars/google/connect?redir=https://myapp.com/callback&isDryRun=false可选 Query:
| Parameter | Type | Description |
|---|---|---|
| redir | string | 授权成功后的回跳地址(需与 OAuth 应用白名单一致) |
| isDryRun | boolean | 干跑模式,不真正换取凭据,直接返回授权链接 |
响应:
{ "status": "success", "data": { "authUrl": "https://accounts.google.com/o/oauth2/auth?..." } }实现上GoogleCalendarService.connect()会用当前租户配置的api.url拼出 OAuth 回调地址{api.url}/gcal/oauth/save,再generateAuthUrl()生成带 scope、state(内含accessToken/origin/redir/isDryRun)的授权 URL(gcal.service.ts)。典型流程:前端拿到 authUrl 后跳转 → 用户在 Google/Microsoft 完成授权 → 平台回调/save→ 后端用code换取并持久化凭据 → 301 重定向回redir || origin。
4.3 OAuth 回调落库:GET /v2/calendars/{calendar}/save
GET /v2/calendars/google/save?state=...&code=4/0AfJohXmB...控制器会先尝试把state按 JSON 解析,失败则退化为 URLSearchParams 解析(字段accessToken、origin、redir、isDryRun),校验后再分派到OutlookService.save()或GoogleCalendarService.save(),最终以 301 跳回业务地址(见 calendars.controller.ts)。
4.4 断开连接:POST /v2/calendars/{calendar}/disconnect
当前实现是POST(参考文档写作 DELETE),请求体为要删除的凭据 ID:
POST /v2/calendars/google/disconnect Content-Type: application/json { "credentialId": 123 }处理逻辑(calendars.controller.ts):先checkCalendarCredentials(credentialId, user.id)校验凭据归属,再调用CalendarsRepository.deleteCredentials()删除,并主动清除该用户的 connected/destination 缓存,最后返回被删除凭据的快照(id、type、userId、teamId、appId、invalid)。
4.5 Apple 日历凭据:POST /v2/calendars/{calendar}/credentials
Apple 不走 OAuth,而是提交应用专用密码:
POST /v2/calendars/apple/credentials Content-Type: application/json { "username": "john@icloud.com", "password": "xxxx-xxxx-xxxx-xxxx" }仅支持apple,由AppleCalendarService.save()处理(calendars.controller.ts)。
五、受支持的日历类型与标识映射
参考文档的表格,并结合 platform/constants/apps.ts 修正后的映射关系如下:
| 日历 | 路由{calendar}标识 | App 元数据 slug | 连接方式 |
|---|---|---|---|
| Google Calendar | google | google-calendar | OAuth(GET connect / save) |
| Microsoft 365 / Outlook | office365 | office365-calendar | OAuth(GET connect / save) |
| Apple Calendar(CalDAV/iCloud) | apple | apple-calendar | POST credentials(用户名+应用密码) |
| 通用 CalDAV / ICS Feed | ics-feed | ics-feed | POST ics-feed/save |
其中google_calendar/office365_calendar/apple_calendar是凭据的type(数据库侧),google-calendar等是 App 展示用 slug,三层标识在 apps.ts 中被集中维护,集成时务必区分使用场景,不要混用。
六、目标日历(Destination Calendar)与选中日历(Selected Calendars)
6.1 两者在可用性模型中的角色
用一句话概括(参考文档核心语义):
- Selected Calendars(选中日历):这些日历里的事件会阻塞可用时间(参与冲突检测)。
- Destination Calendar(目标日历):新预约被创建时写入的日历。
- Busy Times:API 聚合所有选中日历的忙碌时间,供可用 slot 计算排除。
6.2 更新目标日历:PUT /v2/destination-calendars
参考文档描述了「列表目标日历」端点,但当前仓库中该控制器只实现了更新(GET /v2/calendars的响应已内含目标日历,列表场景通常无需单独查询):
PUT /v2/destination-calendars Content-Type: application/json { "integration": "google_calendar", "externalId": "primary" }服务端(destination-calendars.service.ts)会从用户的已连接日历中反查该externalId对应的credentialId;同时支持可选的delegationCredentialId走委托凭据写入场景。若既无delegationCredentialId也反查不到credentialId,则直接报错。e2e 测试验证了更新成功(200)与非法日历类型(400)两种路径。
6.3 新增/删除选中日历
POST /v2/selected-calendars Content-Type: application/json { "integration": "google_calendar", "externalId": "work-calendar-id", "credentialId": 123 }DELETE /v2/selected-calendars?credentialId=123&integration=google_calendar&externalId=work-calendar-id两者的服务层(selected-calendars.service.ts)都会先checkCalendarCredentials(credentialId, user.id)做归属校验,再写/删SelectedCalendar记录。只有处于 selected 状态的日历才会进入 busy-times 冲突检测,这一点与第三节的calendarsToLoad语义配合:busy-times 接口允许显式指定任意已连接日历,而可用性引擎默认只关心 selected 集合。
七、ICS Feed 订阅日历
ICS Feed 是「只读订阅任意 .ics 地址」的轻量接入方式,不涉及 OAuth。
检查 ICS Feed
GET /v2/calendars/ics-feed/check服务端(ics-feed.service.ts)依次检查:是否存在该用户的ics_feed类型凭据 → 凭据是否invalid→ 该凭据是否真的连出了ics_feed日历,任一环节失败都会抛出对应的 400/401,全部通过返回{ "status": "success" }。
保存 ICS Feed
POST /v2/calendars/ics-feed/save Content-Type: application/json { "urls": ["https://calendar.example.com/feed.ics"], "readOnly": true }请求体 DTO 见 create-ics.input.ts:
| Field | Type | Default | Description |
|---|---|---|---|
| urls | string[] | 必填 | ICS 地址数组;自定义校验器IsICSUrlConstraint要求协议为 http/https 且路径以.ics结尾 |
| readOnly | boolean | true | 是否允许写回该日历(只读订阅建议保持默认 true) |
保存逻辑(ics-feed.service.ts):用CALENDSO_ENCRYPTION_KEY对称加密 urls 后 upsert 凭据,并通过BuildIcsFeedCalendarService.listCalendars()预校验「订阅数量 == 实际可列出的日历数量」,不匹配即 400(提示改用私有 ICS Feed);成功后同样清除相关缓存。
八、日历事件读写(Unified Calendar Events)
在/v2/calendars前缀下,仓库还有一个独立的Cal Unified Calendars控制器(cal-unified-calendars.controller.ts),提供事件级 CRUD 与 free/busy。参考文档中的事件端点在这里有对应实现:
8.1 获取单个事件(会议明细)
GET /v2/calendars/google/events/{eventUid}返回会议明细,含id、title、description、start/end(各带time与timeZone)、attendees(含responseStatus)与status、source:
{ "status": "success", "data": { "id": "event-id-123", "title": "Meeting", "description": "Discussion", "start": { "time": "2024-01-15T10:00:00.000Z", "timeZone": "America/New_York" }, "end": { "time": "2024-01-15T11:00:00.000Z", "timeZone": "America/New_York" }, "attendees": [ { "email": "attendee@example.com", "name": "Attendee Name", "responseStatus": "accepted" } ], "status": "accepted", "source": "google" } }eventUid即 Google Calendar 事件 ID,可通过GET /v2/bookings/{bookingUid}/references从预约的 bookingReference 中取得。历史单数路径/:calendar/event/:eventUid已被标记废弃,请使用复数/events/;更多事件级端点包括:
GET /v2/calendars/{calendar}/events?from=...&to=...&timeZone=...&calendarId=...— 列表事件(仅 Google 日历当前支持)POST /v2/calendars/{calendar}/events— 创建事件PATCH /v2/calendars/{calendar}/events/{eventUid}— 更新事件DELETE /v2/calendars/{calendar}/events/{eventUid}— 删除/取消事件(返回 204)GET /v2/calendars/{calendar}/freebusy?from=...&to=...— free/busy 聚合
该控制器同时提供 connection-scoped 变体:
GET /v2/calendars/connections(列出连接,返回connectionId/type/connections/:connectionId/events、freebusy等(cal-unified-calendars.controller.ts)。注意控制器说明:事件 CRUD 目前仅对 Google Calendar 连接生效,其它类型会返回 400。
九、日历集成原理:可用性与同步流
9.1 日历如何影响可用性
参考文档总结的三条因果,与仓库实现完全对得上:
- 选中日历上的既有事件通过 busy-times 阻塞候选 slot;
- 目标日历决定新预约落到哪里;
- busy-times 结果由调度器汇总后从可用时间段中剔除(底层 slot 计算位于 apps/api/v2/src/lib/services/available-slots.service.ts 与
getBusyCalendarTimes的调用链中)。
9.2 完整同步流程
1. 用户连接日历(OAuth / 凭据) GET /v2/calendars/google/connect → 获得 authUrl GET /v2/calendars/google/save ← OAuth 回调,落库凭据 2. 用户勾选参与冲突检测的日历(selected calendars) POST /v2/selected-calendars DELETE /v2/selected-calendars 3. 用户设定新预约写入的目标日历 PUT /v2/destination-calendars 4. 查询可预约 slot 时: - API 从所有 selected calendars 拉取 busy times - busy times 从可用 slot 中排除 5. 预约创建时: - 事件写入目标日历(destination calendar) - 系统向与会者发送确认邮件9.3 Cal.diy 事件识别
Cal.diy 写入外部日历的事件可以通过其iCalUID后缀识别(形如2GBXSdEixretciJfKVmYN8@Cal.diy)。在你的事件同步/去重逻辑中,可用这一标识区分「本平台创建的事件」与「用户手动创建的事件」,避免把平台预约当作普通事件重复处理或误删。
十、团队场景与进阶
参考文档在团队日历集成下列出了会议(conferencing)类端点,可用于团队级管理入口的编排:
GET /v2/organizations/{orgId}/teams/{teamId}/conferencing POST /v2/organizations/{orgId}/teams/{teamId}/conferencing/{app}/connect注意:conferencing 管理的是视频会议应用而非日历写入本身;团队级日历凭据管理与凭据所有权校验的实现细节可进一步查阅CalendarsRepository/CredentialsRepository以及团队 API 路由树,确认其响应结构与个人日历一致后再接入。
十一、生产级集成最佳实践
在参考文档 5 条最佳实践基础上,结合仓库代码补充工程化要点:
- 先检查连接再操作:连接/读写事件前调用
GET /v2/calendars/{calendar}/check;对应实现会区分「无凭据」「凭据 invalid」「未连接」等错误态,便于给出精确提示。 - 处理 OAuth 过期与刷新:日历 token 会过期,需捕获 401 类错误并触发重新授权(重新走 connect/save 或让用户在 Cal.diy 前端重连)。Google/OAuth 客户端构造见 gcal.service.ts。
- 尊重 provider 限流:日历供应商(Google/Outlook)各自有速率配额,聚合查询会放大请求量;busy-times 聚合请批量、低频调用。
- 合理缓存 busy times:
GET /v2/calendars本身已被 CalendarsCacheService 缓存,业务侧对 busy times 也应按需缓存——忙碌信息变化频率远低于查询频率;但记住删除凭据/保存 ICS 等写操作会触发缓存失效,缓存策略要与失效时机对齐。 - 优先 Webhook 而非轮询:订阅预约/事件变更 Webhook,替代对日历反复轮询;这能同时降低 provider 限流风险与自身 API 负载。
- 参数以契约为准:busy-times 的
calendarsToLoad是数组且必须携带timeZone/dateFrom/dateTo,参考文档中的startTime/endTime写法不会生效,接入前对照 busy-times.input.ts 与 e2e 用例修正。 - 凭据归属校验是安全底线:新增/删除 selected calendar、busy-times 查询、disconnect 都内含
checkCalendarCredentials/凭据数量比对,任何绕过校验的「替他查忙、删凭据」设计都不被 API 允许。
十二、小结与速查
| 能力 | 端点 | 备注 |
|---|---|---|
| 已连接日历 + 目标日历 | GET /v2/calendars | 结果含connectedCalendars与destinationCalendar,服务端缓存 |
| 忙碌时间 | GET /v2/calendars/busy-times | 参数timeZone/dateFrom/dateTo/calendarsToLoad |
| 连接检查 | GET /v2/calendars/{google\|office365\|apple}/check | 需APPS_READ权限 |
| OAuth 连接 | GET /v2/calendars/{google\|office365}/connect→/save | GET 发起,返回authUrl |
| 断开 | POST /v2/calendars/{calendar}/disconnect | body:credentialId |
| Apple 凭据 | POST /v2/calendars/apple/credentials | body:username/password |
| ICS Feed | POST /v2/calendars/ics-feed/save、GET /v2/calendars/ics-feed/check | urls 必须以.ics结尾 |
| 选中日历 | POST/DELETE /v2/selected-calendars | 参与冲突检测 |
| 目标日历 | PUT /v2/destination-calendars | 写入新预约 |
| 事件读写 | /v2/calendars/{calendar}/events...与/v2/calendars/connections/{connectionId}/events... | 目前仅 Google 日历支持 CRUD |
以参考文档为主线、以 apps/api/v2 源码为佐证的这组日历 API,是 Cal.diy 调度引擎与用户真实日程之间的桥梁。理解「selected 阻塞、destination 落库、busy-times 聚合、凭据归属校验」这一闭环,你就掌握了在任何日历驱动的调度产品中安全、正确地做可用性集成的核心方法论。
【免费下载链接】cal.diyScheduling infrastructure for absolutely everyone.项目地址: https://gitcode.com/GitHub_Trending/ca/cal.diy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考