Cal.diy 源码分析:功能版图、项目架构与预约调用链
AI 参与说明(Agent:Codex):本文依据 Cal.diy 官方仓库、固定提交源码、官方拆分公告与部署文档辅助调研,初版曾使用项目 Anatomy 制作区间计算交互,该展示已撤下;中文正文使用 humanizer-zh 编辑。资料核对于 2026-10-15,源码基线为
54343aa685ae8f33159d2f485ec4a57bad5c574a。采用建议属于本文判断;源码核验、教学模型与真实部署的验证范围分别说明。运行记录:模型gpt-6.1-sol,reasoning effortultra,执行入口 Codex Desktop,提供方openai;CLI 版本0.162.0-alpha.17.2,不代表桌面 App 版本。
标签整理(2026-10-16,Agent:Codex):本次仅补齐分类标签并核对元数据,未改写或重新校验原文技术结论。运行记录:模型
gpt-6.1-sol,reasoning effortultra,执行入口 Codex Desktop,提供方openai;CLI 版本0.162.0-alpha.17.2(不代表桌面 App 版本)。
源码分析修订(2026-10-16,Agent:Codex):补充功能与代码入口映射、运行架构、模块依赖、数据实体及查询与提交的完整调用链;撤下原区间排版尺和 Labs 入口,保留原锚点。使用项目 Anatomy 的机制建模与教学复核方法,以同一组预约数据解释模块之间的转换,并使用 humanizer-zh 编辑正文。源码仍固定于
54343aa685ae8f33159d2f485ec4a57bad5c574a。运行记录:模型gpt-6.1-sol,reasoning effortultra,执行入口 Codex Desktop,提供方openai;CLI 版本0.162.0-alpha.17.2,不代表桌面 App 版本。
Cal.diy 是以个人预约为主线的 TypeScript monorepo:Next.js Web 与独立 NestJS API v2 提供入口,共享排期、Booking 和日历集成代码,PostgreSQL 保存配置与预订结果。 理解它的关键,是把“从规则算出可约时间”与“提交后建立一次预订”两条链分开,再看它们怎样复用可用性计算。
本文先回答三个代码问题:一个功能从哪个文件进入;请求经过哪些模块;这些模块读写哪些数据。项目拆分、许可与自托管配置放在后面。以下路径与行为均以固定提交为准,不把仍留在目录里的企业代码分支当作当前产品能力。
| 英文术语 | 中文名称 | 简要解释 |
|---|---|---|
| Event Type | 预约类型 | 一组预约规则,例如时长、Buffer、可选地点与是否需要确认 |
| Schedule / Availability | 日程表 / 可用时间 | 每周工作时间与日期覆盖,展开后才能得到某一天的绝对时间区间 |
| Busy | 忙碌区间 | 外部日历、已有 Booking 或限制占用的时间 |
| Slot | 预约时段 | 按时长、时间网格和提前量筛出的可选开始时间 |
| Booking | 预订 | 访客提交后保存的业务记录,具有状态、参与者及外部事件引用 |
| Credential | 集成凭据 | 连接日历、会议等外部服务所需的授权记录 |
| App Store | 应用商店 | 注册并接入日历、会议、支付等第三方能力的代码体系 |
| API v2 | 第二版接口 | 与 Web 自身接口并列的独立 HTTP 服务 |
功能版图:从用户动作定位代码
Cal.diy 的个人主线包括 Event Type 配置、Availability 管理、日历连接、公开预约、Booking 管理,以及按配置触发的通知和集成。下表列出固定源码具有入口或实现的功能;本文没有把所有第三方服务逐一连接验收。
| 用户要做的事 | 对应代码层 | 核心数据与后续行为 |
|---|---|---|
| 创建、修改个人 Event Type | Web 的 Event Types 页面;viewer/eventTypes tRPC Router | 写入 EventType,确定时长、间隔、Buffer、提前量、表单与 Schedule 关联 |
| 设置每周工作时间与日期例外 | Availability 页面;viewer/availability Router | 写入 Schedule 和 Availability;查询时再按时区展开 |
| 安装日历或会议集成 | Apps 页面;packages/app-store 各应用的授权和服务实现 | 保存 Credential;选择冲突检查用的 SelectedCalendar 和写入用的 DestinationCalendar |
| 分享个人预约链接 | apps/web/app/(booking-page-wrapper)/[user]/[type]/page.tsx;共享 Booker UI | 加载 Event Type,取得候选 Slots,收集访客资料 |
| 选择日期并查看 Slots | useSchedule → Web slots tRPC → AvailableSlotsService | 组合 Schedule、Busy、限制和临时 Reservation,返回候选开始时间 |
| 提交一次预约 | POST /api/book/event → RegularBookingService | 重检可用性,创建 Booking、Attendee,再协调外部工作 |
| 改期、取消、确认或拒绝 | Booking 对应的 HTTP / tRPC 入口与服务 | 更新 Booking 生命周期;按分支处理外部事件和通知 |
| 在自有客户端调用接口 | apps/api/v2 的 Controllers 与 Services | 提供 slots、bookings、schedules、event-types 等接口,复用共享业务实现 |
| 收取预约费用、发送邮件、发 Webhook | Booking 服务中的条件分支及各集成 | 依赖实际服务配置;源码中有路径不等于支付或投递已完成 |
入口依据:公开预约页面、Event Types Router、Availability Router、Web 提交接口、API v2 模块。下文展开各入口实际调用的函数。
当前产品范围与代码残留要分开读
官方已移除 Teams、Organizations、Routing Forms、Workflows、Instant Booking、AI Phone、SAML/SSO、Insights、Booking Audit、Impersonation 和 API v1。个人预约主线仍然经过一些来自上游的通用数据结构与分支;例如 Prisma 中还能找到团队字段,Slots 服务仍有聚合逻辑,部分 platform 导出则已改成占位实现。文件存在、类型存在、实际入口可用,是三个不同证据层次。官方删除清单
一个具体例子是 packages/platform/libraries/slots.ts 中的 validateRoundRobinSlotAvailability:移除相关企业模块后,这里的实现直接返回 true。不能据此说系统已经完成团队轮转校验。阅读本文时,先沿个人、单主持人的真实路径建立模型,再单独检查残留分支。platform Slots 导出
项目架构:两套服务入口,共享业务包
The personal booking flow uses the Web application’s own endpoints. API v2 is a separate entry point into shared business code.
公开页面、管理后台和 Web 接口都在 apps/web 内。个人 Event Type 查询 Slots 走 Web 的 tRPC;提交走 Web 的 /api/book/event。独立 API v2 则面向其他客户端,使用自己的认证、Controller、参数转换和服务编排。它不是个人预约页面必须逐次经过的中间层。useSchedule · Web 提交接口
flowchart TB
browser["浏览器"] --> web["apps/web<br/>页面 / tRPC / HTTP"]
client["API 客户端"] --> api["apps/api/v2<br/>Controllers / Services"]
web --> features["共享预约业务<br/>features / Slots util.ts"]
api --> platform["packages/platform<br/>共享导出与适配"]
platform --> features
features --> prisma["packages/prisma"]
prisma --> pg["PostgreSQL"]
features --> integrations["集成协调<br/>CalendarManager<br/>EventManager"]
integrations --> external["外部日历 / 会议<br/>支付 / 通知"]
图表示依赖关系,不表示所有请求都会访问所有节点,也不表示这些模块是独立部署的微服务。packages/features 中很多代码由 Web 或 API 进程直接执行。Redis 出现在独立 API v2 的配置与队列、限流等实现中,不能因此把 Web 的每次预约都画成经 Redis 排队完成。
源码还留有团队 Event Type 的 API v2 查询分流:useSchedule 只有在 useApiV2 && isTeamEvent && enabled 时启用该分支,个人路径则启用 trpc.viewer.slots.getSchedule。这是代码现状;结合当前删除清单,不能反推团队产品能力已恢复。查询分流条件
前端为什么不只在 apps/web 里
公开预约入口位于 Next.js App Router,但入口仍复用旧的 getServerSideProps 适配层和共享 View:page.tsx → users-type-public-view → BookerWebWrapper → Booker。主要视图、日期选择、表单和 Slots 列表在 apps/web/modules/bookings/components/;packages/features/bookings/Booker 主要提供共享状态、Provider、Hooks、配置与工具。查询和提交 Hooks 也分布在 Web 业务模块中。管理页面同样常把路由壳、View 与业务代码拆开。只扫描 apps/web/app 会低估实际实现规模。
管理端 /event-types 则先检查会话,再通过 createRouterCaller 直接调用 Event Types Router 取得服务端数据;这一步不必向本机发一次 HTTP 请求。管理页
服务端也有类似组织:packages/trpc 定义 Web RPC 边界,Handler 再调用 packages/features 中的服务;apps/web/pages/api 中仍保留直接 HTTP 路由。这里同时使用 App Router 和 Pages API 路由,应从具体请求入口追踪,不能按一种框架目录约定猜出全部链路。公开页面 · Booker 主视图 · 共享 Booker 状态 · Web API 目录
代码架构:目录怎样承担职责
apps/
web/ 页面、会话、Web tRPC 与 HTTP 入口
api/v2/ 独立 API 的认证、Controller 与 Service
packages/
features/
bookings/ 共享状态与 Hooks、创建/改期/取消编排
availability/ 用户可用性计算
schedules/ 工作时间展开、区间运算、Slots 生成
busyTimes/ 日历与已有 Booking 的占用整理
trpc/ Web Router、输入 Schema 与 Handler
prisma/ schema、数据库访问与迁移
app-store/ 应用注册、授权及具体集成
platform/ API 可复用的业务导出和适配
lib/ 跨功能工具与公共实现
ui/ 共享界面组件
根 package.json 通过 Yarn Workspaces 纳入应用、features、App Store 等包,再由 Turborepo 组织任务。这是部署入口分开、业务代码大量共享的 monorepo。根 package.json
模块名不等于严格的分层边界。features/bookings 同时包含前端共享状态、Hooks、部分组件和服务端 Booking 编排;schedules 包含更接近算法的函数;trpc 中的 Slots util.ts 又承担了较多业务组合。判断一个文件属于哪层,应看它是否依赖请求上下文、数据库、外部服务或 React,而不能只看它叫 lib 还是 service。
| 层次 | 具体职责 | 更适合在这里修改的内容 |
|---|---|---|
| 页面与 UI | 展示、表单、选择日期、触发查询和提交 | Booker 布局、交互、字段呈现 |
| 协议入口 | 解析输入、取得身份与上下文、调用服务、映射错误 | tRPC 输入、Web HTTP 字段、API v2 DTO / Controller |
| 业务编排 | 选 Schedule、读 Busy、校验限制、建立 Booking、协调副作用 | 可预约条件、预约流程与分支 |
| 计算函数 | 区间展开、差集、Slots 时间网格 | 时段算法;需特别核对时区和边界 |
| 持久化与集成 | 保存实体、读写日历、创建会议、通知与支付 | 数据约束、新 Provider、失败恢复 |
RegularBookingService.ts 最能体现当前代码的集中程度:核心 Handler 约两千行,公开的 createBooking 与 rescheduleBooking 再委托给它。数据库写入、通知、外部事件和多种历史分支集中于此。它便于沿单一路径追踪业务,但修改一处时需要辨认当前请求会进入哪些条件分支,不能把文件里的所有分支都理解为固定顺序。共享 Handler 与公开方法
核心数据模型:规则、占用与结果分别保存
Prisma schema 使用 PostgreSQL。核心模型可分成四组:预约规则、外部连接、预订结果、短期 Reservation。Prisma schema
erDiagram
Schedule o|--o{ EventType : configures
Schedule o|--o{ Availability : contains
EventType o|--o{ Booking : describes
Booking o|--o{ Attendee : includes
Booking o|--o{ BookingReference : references
这张图聚焦规则如何关联到 Booking,再关联参与者与外部事件引用;可选关系保留其可选性。用户、Credential、日历选择与 Payment 的职责见下表,团队、座位和历史兼容字段未画入。
| 模型 | 保存什么 | 查询或提交时怎样使用 |
|---|---|---|
EventType | length、slotInterval、Buffer、minimumBookingNotice、日期范围、确认和支付相关配置 | 规定候选怎样生成、提交怎样校验 |
Schedule / Availability | Schedule 时区;每周 days、工作起止时间,或指定日期覆盖 | 展开为具体日期的绝对起止时间;不能直接当成已预订事件 |
Credential | 用户与集成的授权记录 | 为 App Store 中对应的日历、会议等实现提供授权 |
SelectedCalendar | 哪些日历参与冲突检查 | Busy 读取范围;与写入目标并非同一概念 |
DestinationCalendar | 新事件应写到哪里 | 外部 Calendar 创建或更新时的目标 |
Booking | uid、绝对 startTime/endTime、状态、Event Type 与关联记录 | 本地一次预订的事实;可在外部服务失败时仍然存在 |
Attendee / BookingReference | 参与者;外部日历或会议的标识、链接与凭据关联 | 通知参与者,后续更新或删除对应的外部事件 |
Payment | Booking 关联的支付记录和支付状态 | 与 Booking 状态一起判断后续流程,不能用“有 Booking”代替“已付款” |
SelectedSlots | 被临时选择的时间、客户端标识和 releaseAt | 查询时排除其他访客尚未过期的占用;与最终 Booking 分开 |
例如,Availability 中的“每周一 09:00–12:00”需要结合 Schedule 时区和目标日期展开;Booking 的 2026-10-19T11:30:00Z 则已经是某次事件的绝对开始时间。前者产生候选,后者会成为后续查询的 Busy 来源。Schedule 与 Availability · Booking
Booking 生命周期不等于一个布尔值
创建路径会根据是否需要确认、是否需要支付等条件,决定初始状态;正常已接受的预约使用 ACCEPTED,待处理路径可能是 PENDING。支付还有独立数据,不能仅凭一个状态字段推导所有流程已完成。创建状态分支
改期复用 RegularBookingService 的主要 Handler,并把原 Booking 上下文带入;普通非 Seats 改期中,createBooking.ts 的局部事务会取消旧记录并创建新记录,fromReschedule 保存业务关联,Seats 另有处理。取消则先把本地 Booking 标为 CANCELLED,随后删除对应外部事件;外部删除失败会记录错误。由此可见,Booking 生命周期和第三方事件状态需要分别观察。改期与创建委托 · 局部事务 · 取消服务
待确认记录的后续操作可从 viewer/bookings/confirm.handler.ts 进入:服务核对操作者,再分别将 PENDING 确认成 ACCEPTED,或拒绝成 REJECTED;需要确认的创建分支不会立即建立外部 Calendar 事件。确认与拒绝 · 待确认创建分支
查询链:一个日期怎样变成候选 Slots
个人 Event Type 的请求从公开页面进入,经 Booker 与 useSchedule 查询 Web 的 slots tRPC。服务端再取得 Event Type、相关用户、Schedule 与 Busy,计算可用区间,并生成 Slots。
flowchart TB
page["/[user]/[type]<br/>页面与 Booker"] --> hook["useSchedule<br/>个人 Event Type"]
hook --> trpc["Web tRPC<br/>viewer.slots.getSchedule"]
trpc --> service["AvailableSlotsService<br/>取得配置并组合结果"]
service --> availability["UserAvailabilityService<br/>工作时间、Busy、限制"]
availability --> ranges["扣除占用后的 dateRanges"]
ranges --> slots["getSlots<br/>时长、频率、提前量、对齐"]
slots --> reserved["过滤他人 SelectedSlots<br/>及其他条件"]
reserved --> result["返回候选开始时间"]
精确文件链以入口、Router、Handler、Service 和计算函数为索引,避免从大目录盲读:
| 顺序 | 文件或符号 | 要观察的输入 / 输出 |
|---|---|---|
| 1 | apps/web/app/(booking-page-wrapper)/[user]/[type]/page.tsx | 路由参数与页面初始化数据 |
| 2 | apps/web/modules/schedules/hooks/useSchedule.ts | 日期范围、Event Type、时区与是否启用查询 |
| 3 | apps/web/pages/api/trpc/slots/[trpc].ts → slots _router.tsx → getSchedule.handler.ts | 挂载 Router,校验输入,取得服务实例 |
| 4 | packages/trpc/server/routers/viewer/slots/util.ts | AvailableSlotsService 组合 Event Type、用户和时段 |
| 5 | packages/features/availability/lib/getUserAvailability.ts | Schedule 与 Busy → dateRanges,还返回时区和相关元数据 |
| 6 | packages/features/schedules/lib/slots.ts | dateRanges 与时长、频率、提前量 → Slots |
| 7 | Slots util.ts 的 Reservation 过滤 | 去掉其他访客有效占用,组织返回结果 |
来源:useSchedule · Web tRPC 挂载 · Slots Router · getSchedule Handler · AvailableSlotsService · Slots 生成函数。
第一步:把时间规则展开为绝对区间
buildDateRanges 及其工作时间处理读取每周规则、日期覆盖与时区等条件,产生目标日期内的起止时间。这个过程与“扣除 Busy”分开。夏令时、旅行时区与覆盖规则会影响这一步,不能把字符串 09:00 直接当成任意日期的 UTC 09:00。工作时间展开
第二步:读取 Busy,并把 Buffer 计入冲突区间
getBusyTimes 读取本地主持或参加的 Booking,并通过所选日历和凭据取得外部 Calendar Busy。已有 Booking 还带有它自身 Event Type 的 Buffer;改期时会排除对应原预订,带座位的情况也有不同处理。Busy 实现
这里有一个容易看反的细节:对于外部 Calendar Busy,系统向前扩展的是待预约 Event Type 的 afterEventBuffer,向后扩展的是 beforeEventBuffer。原因是待约活动若安排在 Busy 前,它的结束后 Buffer 不能碰到 Busy;若安排在 Busy 后,它的开始前 Buffer 不能碰到 Busy。已有本地 Booking 还会叠加该 Booking 自己前后的 Buffer。Buffer 计算
第三步:用 subtract 得到剩余 Availability
UserAvailabilityService 整理日历 Busy、Booking Busy 和限制后,调用 subtract(dateRanges, formattedBusyTimes)。subtract 按 Busy 开始时间排序,在每段可用区间中推进游标:跳过已结束占用,输出占用前的空隙,再跳过占用覆盖;最后输出尾段。它输出区间,并保留源区间附带的其他字段。可用性汇总 · subtract
第四步:把区间离散成 Slots
getSlots 还要处理完整预约时长、slotInterval 对应的频率、minimumBookingNotice、时区中的网格对齐、可选偏移和 Out of Office 元数据。它读取当前时间与环境变量,因此不是只由两组区间决定的纯差集函数。Slots 服务还会继续过滤临时 Reservation。调用参数 · getSlots · Reservation 过滤
贯穿示例:同一个请求在各模块中的数据
本节解剖的是完整查询链中几个关键转换:工作时间 → 扩展后的 Busy → 剩余区间 → 候选开始时间 → 排除 Reservation。固定为单主持人、UTC 日期 2026-10-19,当前时间固定在 08:00;外部日历成功返回,没有本地已有 Booking、Out of Office、偏移或优化 Slots 分支。
{
"schedule": ["09:00", "12:00"],
"calendarBusy": [["10:00", "10:30"]],
"length": 30,
"slotInterval": 30,
"beforeEventBuffer": 10,
"afterEventBuffer": 5,
"minimumBookingNotice": 60,
"otherReservation": ["11:00", "11:30"]
}
这是为了阅读而压缩的教学输入,不是某个 HTTP 接口的完整请求体。以下时间均在同一 UTC 日期;区间写成 [开始,结束),用于说明占用边界。
| 阶段 | 传出的具体数据 | 为什么得到这个结果 |
|---|---|---|
buildDateRanges | [09:00,12:00) | 本例时区固定为 UTC,没有日期覆盖 |
getBusyTimes | [09:55,10:40) | Calendar Busy 10:00–10:30,前减 after 5 分钟、后加 before 10 分钟 |
subtract | [09:00,09:55)、[10:40,12:00) | 从允许工作时间中扣除扩展后的占用 |
| 提前量下界 | 09:00 | 固定 now 08:00 + 60 分钟 |
getSlots | 09:00、11:00、11:30 | 09:30 放不下完整 30 分钟;第二段起点 10:40 对齐到 11:00 |
| Reservation 过滤 | 09:00、11:30 | 别人的有效 11:00–11:30 占用排除了 11:00 |
这组数据也说明 length 与 slotInterval 的分工:前者决定一次活动要装下多少分钟,后者影响开始时间的频率与对齐。空闲总分钟数无法直接除以时长得到最终可约次数。
可以用两次输入变化检查是否读懂了规则:
- 把
afterEventBuffer从 5 改成 0:Busy 左界变为10:00,09:30–10:00恰能容纳,候选增加09:30。变化来自 Buffer 层,随后通过差集影响 Slots。 - 把
length从 30 改成 45,slotInterval保持 30:11:30放不下完整活动;11:00又被上述 Reservation 排除,因此最终只剩09:00。变化来自完整时长检查,时间网格仍按 30 分钟。
本例按固定源码的分钟级输入说明主要规则。原 getSlots 的结束判断包含减去 1 秒的实现细节,不能将本例概括为适用于任意秒级输入的数学契约;时区、DST、座位、多人聚合及外部故障也不在这组输入中。
提交链:选中的 Slot 怎样成为 Booking
访客提交 11:30–12:00 时,前一次查询结果不会直接成为数据库锁。Web 接口把请求交给 RegularBookingService,服务重新检查,再持久化并处理后续工作。
flowchart TB
post["POST /api/book/event<br/>解析请求"] --> regular["RegularBookingService<br/>创建或改期的共享 Handler"]
regular --> inputs["检查时间、时长与相关限制"]
inputs --> available["ensureAvailableUsers<br/>以 booking 模式重读 Availability"]
available --> decision{"整个请求区间仍可用?"}
decision -->|否| reject["拒绝当前请求"]
decision -->|是| create["createBooking<br/>本地数据库写入"]
create --> event["按条件创建或更新<br/>外部 Calendar / 会议"]
event --> later["按分支处理通知、Payment、Webhook"]
这张图展示本文选择的正常个人创建路径。支付、待确认、重复活动、座位与改期都会改变分支,不能把后半段理解为每次都执行、且必须全部成功的一条固定流水线。
| 阶段 | 代码位置 | 实际作用 |
|---|---|---|
| 解析 HTTP 请求 | apps/web/pages/api/book/event.ts | 调用 Booking 服务,映射响应或异常 |
| 取得业务上下文 | RegularBookingService 共享 Handler | 加载 Event Type、用户、凭据与可能的改期信息 |
| 检查输入与限额 | 同文件约 764–821 行 | 校验开始结束、活动时长和 Booking / duration limits |
| 重检可用性 | 约 933–946 行 → ensureAvailableUsers | 重新取得 Availability;正常路径不能仅信任先前候选 |
| 本地建立记录 | 约 1705–1733 行 → createBooking | 建立 Booking、参与者等关联;改期事务处理旧记录 |
| 外部事件 | 约 2050 行起的对应分支 | 通过 EventManager 创建 Calendar / 会议事件 |
| 通知与其他后续工作 | 同文件后续条件分支 | 处理邮件、支付请求与 Webhook 等 |
来源:HTTP 入口 · 提交检查 · 完整区间重检 · 数据库写入 · 外部事件与通知 · 支付 · Webhook。
重检检查的是完整区间,不是重新展示候选列表
ensureAvailableUsers 在 booking 读取模式下重新取得可用性,显式保留日历 Busy 检查,并要求整个请求区间落入允许范围,再检查冲突。RegularBookingService 还设置 Booking fetch mode 绕过日历缓存。这里复用了可用性业务;它没有把前端显示过的 getSlots 结果当成提交许可。读取模式 · ensureAvailableUsers
例如,在显示 11:30 后,外部日历新增 11:40–12:10 事件,提交时读到的 Availability 就可能无法容纳原来的完整请求。反过来,仅改变页面排列或把 11:30 从界面藏起来,也不能代替服务端拒绝非法提交。
Reservation、幂等键与事务分别解决什么
SelectedSlots 带有 releaseAt,查询时会清除过期项并排除其他访客的有效占用。它的唯一键包含客户端标识,预约前的 reserve 路径也有检查再写入步骤;这不构成“全局时间范围原子互斥”的证明。Reservation 读取与过滤 · reserve Handler · 模型
Booking 还包含可选且唯一的 idempotencyKey,字段用于重复请求识别,但普通 Web 创建路径并未据此提供完整幂等保证;不能由字段存在推导重复提交一定只创建一条记录。这个键也不约束两个不同请求的时间区间重叠。普通非 Seats 改期时的数据库事务仅覆盖旧 Booking 更新和新 Booking 创建,也没有把外部 Calendar、邮件或支付一起纳入事务。Booking 模型 · createBooking 事务
日历读取失败与外部写入失败要分别看
读取 Busy 时,源码根据 silentlyHandleCalendarFailures 决定抛出异常还是记录并继续;UserAvailabilityService 的异常处理又会影响返回结果。因此不能把所有集成失败概括为一律拒绝,或一律忽略。Busy 异常分支 · 可用性异常处理
写入侧则可能已经有本地 Booking,再创建外部事件或发送通知。数据库有记录、外部日历有事件、邮件已送达,是三项不同结果。需要验证某个集成时,应分别观察这些结果及对应错误路径;本文没有据源码中存在重检或事务,就宣称并发竞态、重试和外部副作用已经得到端到端保证。
集成架构:如何接入一个日历或会议服务
集成可以沿三个层次阅读:App Store 组织应用和授权;CalendarManager 统一日历读写;EventManager 协调一次 Booking 对应的日历与会议工作。
Credential 保存用户授权,SelectedCalendar 决定读取哪些日历来检查冲突,DestinationCalendar 决定新事件写到哪里,BookingReference 再保存创建结果的外部标识。这条数据链解释了为什么“登录 Google”“连接 Google Calendar”“把 Booking 写入 Google Calendar”需要分开配置和核对。
新增 Provider 时,先看现有同类 App 的元数据、授权回调与服务实现如何接入管理层,再看 Busy 读取和 EventManager 写入怎样使用它。只新增 App 图标和安装页面,无法完成时间冲突读取、事件写入和取消改期。App Store · CalendarManager · EventManager
任务调度也要分清实现:packages/features/tasker 的通用后台任务默认使用 InternalTasker 与数据库任务记录,RedisTasker 仍标为未实现;普通预约邮件有直接调用 emailsAndSmsHandler.send 的路径,另有受配置与功能开关控制的同步 / Trigger.dev tasker。它们不能合并画成一套 Redis 消息队列。通用 Tasker · 普通通知调用 · 另一套 Tasker 策略
API v2:复用业务,而不是复制整个 Web 流程
API v2 的 Controller 负责路径、认证及输入输出;Service 通过共享包和 platform 导出接入排期与 Booking 实现。阅读它时,先定位具体 endpoint,再追踪调用,避免从 Web Hook 反猜 API 行为。
| 接口范围 | 固定源码中的入口 | 需要注意的区别 |
|---|---|---|
| Slots | /v2/slots;slots-2024-09-04 Controller | 查询可用时段,另有 Reservation 相关方法 |
| Bookings | /v2/bookings;2024-08-13 Controller | 创建、列出、改期、取消、确认与拒绝有不同方法和认证条件 |
| Schedules | 版本化的 schedules Controller | 管理 Schedule;不是直接创建 Booking |
| Event Types | 版本化的 event-types Controller | 管理预约规则;不是候选 Slots 本身 |
API 创建路径也能追到同一业务类:BookingsController → 版本化 BookingsService → inputService 转换 body → API 侧 RegularBookingService。最后这个类通过 extends 继承 platform-libraries/bookings 导出的共享类,再由 Nest 注入仓库、Prisma 和任务依赖;Web 则通过自己的容器装配。因此两种协议入口具有不同的认证和字段形式,同时复用核心创建逻辑。API 输入转换 · 继承与依赖注入 · 共享导出
源码中的接口版本是按资源演进的,不能用一个日期概括所有 API v2 端点。访问控制也要按具体方法查看,不能由“支持 API Key”推导所有操作要求相同。Slots Controller · Bookings Controller
源码阅读与二次开发路线
如果目标是理解代码,建议按下表完成一次纵向阅读,再横向扩展集成和异常分支。每一步都应能指认输入、输出和下一跳,而不是只记住目录名。
| 阅读顺序 | 读完应能回答的问题 | 关键入口 |
|---|---|---|
| 1. 认识实体 | 哪些是规则,哪些是绝对时间,哪些保存外部结果? | schema.prisma 中 EventType、Schedule、Booking、Credential |
| 2. 看个人页面 | 选日期会触发哪次请求,提交走哪个接口? | 公开页面、Booker、useSchedule、/api/book/event |
| 3. 看可用性 | 工作时间、Busy、Buffer 和限制怎样组合? | getUserAvailability、getBusyTimes、date-ranges |
| 4. 看 Slots | 空闲区间为什么只生成这些开始时间? | Slots util.ts、schedules/lib/slots.ts |
| 5. 看写入 | 服务端重检什么,本地事务覆盖什么? | RegularBookingService、ensureAvailableUsers、createBooking |
| 6. 看外部结果 | 日历、会议、通知失败后会留下什么状态? | EventManager、CalendarManager、取消与通知分支 |
| 7. 看另一套入口 | API v2 如何转换输入并复用上述业务? | 对应 Controller → Service → platform / features |
实际改动也应沿职责定位:改预约表单先找 Booker;改时段对齐先看 getSlots;改“能否预约”先看读取与提交两边的可用性;新增日历先追凭据、Busy 与外部事件的完整链。修改查询端而遗漏提交端,容易造成页面显示与服务端判断不一致。
以下命令仅用于检索固定源码,不会连接真实服务。前置条件是已安装 Git 与 ripgrep:
git clone https://github.com/calcom/cal.diy.git
cd cal.diy
git checkout --detach 54343aa685ae8f33159d2f485ec4a57bad5c574a
rg -n 'class AvailableSlotsService|UserAvailabilityService|getSlots\(' \
packages/trpc/server/routers/viewer/slots packages/features
rg -n 'createBooking|rescheduleBooking|ensureAvailableUsers' \
apps/web/pages/api/book packages/features/bookings/lib
rg -n '^model (EventType|Schedule|Availability|Booking|Credential|SelectedSlots)' \
packages/prisma/schema.prisma
预期能分别定位 Slots 组合、Booking 创建与核心模型。安装和运行则应继续按根 packageManager、锁文件及该提交的 Dockerfile 核对,不能把检索成功视为项目已启动。
项目背景、许可与采用边界
2026 年 4 月,Cal.com 将生产代码迁入私有仓库,原公共仓库 calcom/cal.com 改名为 calcom/cal.diy。它承接了原仓库历史,Stars、Forks 和 Releases 因此混有拆分前的积累;评估社区版维护应看拆分后的具体提交与处理记录。官方技术拆分公告
当前根 LICENSE 是 MIT;历史标签需分别检查,例如 v6.2.0 根 LICENSE 仍为 AGPLv3。当前 MIT 不能追溯套用所有旧版本。官方 README 推荐个人、非生产用途,这是当前维护与适用范围提示;源码许可本身不等于官方提供托管、SLA 或第三方账户。当前 LICENSE · 历史 LICENSE · README
截至本次核查,所分析 main 提交日期为 2026-09-20,最新正式 Release v6.2.0 发布于 2026-03-01,早于拆分。历史 Release 不能替代当前主线的可复现构建物;也不能仅由提交间隔推导项目已停止维护。固定提交 · Releases
自托管要核对哪些实际条件
固定 Compose 包含 PostgreSQL、Redis、Web、独立 API v2 与 Prisma Studio;个人 Web 主线不以独立 API v2 容器为必经节点。原文件还映射了 Studio 与 Redis 端口,镜像没有完整固定版本,不应直接当作可重复、适合公开部署的清单。Compose
| 条件 | 对应配置与核验 |
|---|---|
| 数据库与迁移 | DATABASE_URL、DATABASE_DIRECT_URL;Compose 另用 POSTGRES_* 和 DATABASE_HOST 拼接连接串 |
| 公开地址与登录 | NEXT_PUBLIC_WEBAPP_URL、NEXTAUTH_URL;构建与运行地址需一致 |
| 会话与凭据 | NEXTAUTH_SECRET、CALENDSO_ENCRYPTION_KEY;真实密钥需安全保存并纳入恢复 |
| 日历与通知 | 自己的 OAuth 客户端、授权范围、EMAIL_SERVER_* 与 EMAIL_FROM |
| Web Push | NEXT_PUBLIC_VAPID_PUBLIC_KEY、VAPID_PRIVATE_KEY |
| 独立 API v2 | 另核对 Redis、JWT 和启动所需变量;无支付配置时的占位值不能证明支付可用 |
Google Calendar callback 为 /api/integrations/googlecalendar/callback,Google 登录 callback 为 /api/auth/callback/google,用途不同。需要在自己的客户端配置真实域名与授权,仓库不提供公共 OAuth 凭据。环境样例 · API v2 排障 · Google 集成文档
首次评估应固定源码或实际镜像 digest,在隔离环境完成创建 Event Type、设置 Availability、连接日历、访客预约、取消和改期,分别核对本地 Booking、外部事件与通知。再增加跨时区、并发和外部失败场景,并演练数据库及密钥恢复。本文建议从个人预约与源码学习场景评估;需要团队、组织、路由、自动工作流或托管支持时,当前产品范围并不匹配。
本次验证范围
本文固定一份源码读取,核对了 Web 与 API v2 入口、共享服务、核心 Prisma 模型和日历集成调用关系;产品删除范围、许可与部署条件另与官方资料交叉核对。此前已通过 Context7 CLI 查询 Cal.diy 部署与 Google 集成文档;本次代码分析以固定源码为主。
贯穿示例从固定提交抽取原 subtract、getSlots 及辅助函数、Calendar Busy Buffer 映射表达式和 checkForConflicts,固定 UTC 时钟与环境变量运行了基准输入及两组参数变化,结果与正文一致。主验证使用仓库指定的 Dayjs 1.11.4 及其 timezone patch,只隔离监控包装;另以现有 Dayjs 1.11.23 交叉运行,结果相同。工作时间展开后的区间和日历 Busy 为测试输入,没有执行完整 Schedule 展开、AvailableSlotsService 或 Reservation 数据库路径。
原局部 subtract 教学图曾完成 1,900 组局部对照,这不能证明完整项目或图形的讲解效果;本次已撤下该展示。当前静态正文保留每层输入、输出、计算原因与参数变化,旧 cal-diy-availability 锚点仍指向贯穿示例。
本次未启动完整 Cal.diy、连接真实 Calendar / SMTP、创建真实 Booking,也未完成支付、并发或恢复验收。文中的路径与行为分析可以帮助定位代码,不能替代针对实际部署和集成的运行验证。