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 版本。
Cal.diy 值得作为个人预约系统和排期引擎的源码研究对象;把它用于对外业务,需要自行承担功能核对、部署维护和安全责任。 它保留了 Cal.com 的核心排期与预订代码,但已移除多项团队和企业能力。官方目前明确推荐个人、非生产用途,没有把它承诺为 Cal.com 商业服务的等价自托管版本。当前 README
如果需求是“分享一个链接,让别人从自己的空闲时间里预约”,项目的主线很清楚。如果需求包含团队轮转、线索路由、自动跟进或企业 SSO,应先看下文的删除清单,避免投入部署后才发现产品范围不符。
| 英文术语 | 中文名称 | 简要解释 |
|---|---|---|
| Event Type | 预约类型 | 一种可被预约的活动配置,例如 30 分钟交流 |
| Availability | 可用时间 | 用户允许接受预约的时间范围 |
| Busy | 忙碌区间 | 日历事件、已有预订或其他限制占用的时间 |
| Slot | 预约时段 | 经过可用性和时长等条件筛选后,可供选择的开始时间 |
| Booking | 预订 | 访客提交预约后形成的业务记录 |
| App Store | 应用商店 | 连接日历、会议和支付等服务的集成框架 |
| API v2 | 第二版接口 | 与 Web 应用分开的接口服务,采用独立部署配置 |
它与 Cal.com 是什么关系
2026 年 4 月,Cal.com 将生产代码迁入私有仓库,原公共仓库 calcom/cal.com 改名为 calcom/cal.diy。官方 README 用“fork”描述产品关系,但从仓库历史看,它承接了原公共仓库。因此页面上的历史 Stars、Forks、提交和 Releases 都混有拆分前的积累,不能直接衡量新社区版本的维护状况。官方技术拆分公告
The current source tree uses MIT; historical tags must be checked separately. 当前根目录 LICENSE 是 MIT。检查旧标签 v6.2.0,其根 LICENSE 仍为 AGPLv3;当时的企业目录还带有自己的商业许可。下载旧版本时,应按那个版本的文件核查,不能把仓库首页今天显示的 MIT 追溯套到所有历史版本。当前 LICENSE · v6.2.0 LICENSE
官方推荐个人、非生产用途,是维护与适用范围的提示,不能直接解释为当前 MIT 文本禁止商业使用。另一方面,MIT 的许可也不等于官方提供生产支持、SLA 或外部服务使用权。本文只核对仓库文件,不替具体分发方案作法律判断。
保留与删除的能力
| 范围 | 当前判断 | 对选型的影响 |
|---|---|---|
| 个人排期、预约页面、Booking 流程 | 核心源码保留 | 可以围绕个人预约评估 |
| App Store 与日历、会议集成框架 | 保留,具体服务仍需凭据与配置 | 自托管不等于获得共享的 OAuth 或会议服务账户 |
| API v2 | 保留 | 应以该分支的 Controller 和配置核对接口 |
| Teams、Organizations | 官方明确移除 | 不能把多人账户等同于团队排期和组织管理 |
| Routing Forms、Workflows、Instant Booking | 官方明确移除 | 不能期待自动线索路由、完整工作流或即时预约引擎 |
| SAML/SSO、Insights、Booking Audit、Impersonation | 官方明确移除 | 不具备对应企业能力的产品承诺 |
| AI Phone、API v1 | 官方明确移除 | Cal.ai 产品页和旧 API 示例不能直接套用 |
删除清单来自官方技术公告;保留的核心能力同时在固定源码中核对。配置文件仍能看见一些旧功能名称和 Stripe、license 字段,这只能证明历史痕迹存在,不能证明相应产品入口仍然可用。官方删除清单 · 当前 Compose
从预约链接到 Booking
访客打开一个 Event Type 后,系统需要解决两个不同的问题:现在能展示哪些 Slots,以及提交的那个时间此刻是否仍可预订。
| 模块 | 主要职责 | 阅读入口 |
|---|---|---|
apps/web | 账户、Event Type 管理和公开预约界面 | Next.js、React、NextAuth、tRPC |
packages/features/availability | 读取日程规则、日历 Busy 和已有 Booking,得到可用区间 | getUserAvailability.ts |
packages/features/schedules | 日期区间计算 | date-ranges.ts |
packages/trpc/server/routers/viewer/slots | 把可用区间与时长、间隔、提前量等配置组合 | util.ts |
packages/features/bookings | 提交校验、保存 Booking、协调外部副作用 | RegularBookingService.ts |
packages/prisma | 数据模型、迁移与数据库访问 | PostgreSQL、Prisma |
apps/api/v2 | 独立 HTTP API | NestJS;Compose 中还连接 Redis |
这是一个 Yarn Workspaces 与 Turborepo 管理的 TypeScript monorepo,Web 与独立 API 共享较多业务包。它的学习价值主要在排期业务和跨服务协作,而不只是预约页面的 UI。根 package.json · Web package.json · API package.json
数据库也把规则、连接与结果分开:EventType 保存时长、间隔、Buffer 和提前量等配置;Schedule 与 Availability 表达工作时间和日期覆盖;SelectedCalendar 指定哪些外部日历参与;Booking 保存实际预订,Attendee 和 BookingReference 分别关联参与者与外部事件。读这些模型时,应区分“每周允许预约的时间规则”与“某次预订的绝对起止时间”。Prisma schema
下面只画正常的个人预约主线。取消、改期、审批和支付相关分支需要分别阅读,不能由这张简图推断全部状态。
flowchart TB
event["Event Type 与 Availability"] --> available["UserAvailabilityService"]
busy["日历 Busy 与已有 Booking"] --> available
available --> ranges["扣除 Busy 后的可用区间"]
ranges --> slots["getSlots:时长、间隔与提前量"]
slots --> choose["访客选择 Slot 并提交"]
choose --> check["RegularBookingService:重新检查可用性"]
check -->|"不满足条件"| reject["拒绝当前请求"]
check -->|"检查通过"| save["数据库创建 Booking"]
save --> effects["按条件处理日历、会议、邮件、支付和 Webhook"]
UserAvailabilityService 先整理外部日历、已有 Booking 和限制产生的 Busy,再从日程规则得到的区间里扣除它们。Slots 层随后把 eventLength、slotInterval 等参数交给 getSlots。因此,“有一段空闲区间”与“能展示某个开始时间”是两层判断。Busy 整理与扣除 · Slots 生成
一段 Busy 怎样改变 Availability
这里解剖的是 packages/features/schedules/lib/date-ranges.ts 的 subtract,它位于可用性计算内部:输入两组日期区间,输出扣除占用之后的剩余区间。结果继续交给 Slots 层使用,此处还没有创建 Booking,也没有完成最终 Slot 筛选。subtract 源码
教学场景固定为同一 UTC 日期:Availability 是 [09:00,17:00),两段 Busy 分别为 [10:00,11:00) 和 [14:00,15:30)。方括号包含开始,圆括号不包含结束;图中的分钟刻度用来解释严格重叠判断。真实项目传入的是日期对象,图形对有效非空区间重建这个局部函数的行为。图中 Busy 时长设为零时,会在调用前忽略该输入;这属于教学界面的归一化,上游 subtract 本身没有相同过滤,不能把两者混为一谈。
第一段 Busy 把原区间切成 [09:00,10:00) 与 [11:00,17:00);第二段保留前一段,再把后一段切成 [11:00,14:00) 与 [15:30,17:00)。最终留下三段,共 330 分钟。这个总数不等于可约次数:例如 45 分钟预约如何对齐、提前多久才可约,还要看后续条件。
Busy 怎样把一段开放时间切开
四条时间槽使用同一把尺。空隙就是被 Busy 排除的部分,亮色片段是差集。
- 原始开放
- 09:00–17:00
- Busy A
- 10:00–11:00
- Busy B
- 14:00–15:30
- 剩余区间
- 09:00–10:00、11:00–14:00、15:30–17:00
按开始时间排序,取前 N 段 Busy 重新计算:
取前 2 段 Busy 为输入,扣除 150 分钟;剩余 330 分钟,分为 3 段。
与源码核对
复现固定提交中 subtract() 的排序、游标推进和左右段输出。相接端点不会删去时间;相互重叠或跨出开放范围的 Busy 按同一规则处理。UI 的零时长 Busy 不传入差集函数。
这里只计算同一天的区间差集。剩余区间还须经过时长、间隔等约束才会成为 Slots;本图没有调用 Cal.diy,也没有处理时区、Buffer、外部日历同步或预约写入。
时间排版尺是本文的视觉比喻。原始范围、Busy 挡片和输出片段共享一条时间刻度,调节 Busy 的开始与长度,就能看见哪一段被截短、切开或移除。装置的零件不代表上游服务或执行线程;图形、总分钟数和文本结果来自同一个教学模型。
源码先按开始时间排列 Busy,再对每段 Availability 维护一个 currentStart。Busy 已在游标之前结束,就跳过;Busy 在 Availability 结束处或之后开始,就停止扫描;Busy 开始处若还留有空隙,就输出这段空隙,然后把游标推进到 Busy 的结束。扫描完成后,再输出游标到原区间结束之间的剩余部分。这样既能切开内部占用,也能处理首尾截短和整段覆盖;两个 Busy 重叠时,游标只向后推进。源码还会保留源区间附带的其他字段。
重叠边界使用严格比较:Busy 恰好在游标处结束会被跳过,恰好从 Availability 结束处开始会终止扫描。图中的分步按钮表示逐渐加入 Busy 输入后重新计算,用来比较结果,不代表上游按界面步骤运行。
可以先预测这三种变化,再操作图形验证:
- 把第二段 Busy 改成
[10:30,12:00):输出是[09:00,10:00)、[12:00,17:00)。 - 把第二段 Busy 改成
[11:00,12:00):它与第一段首尾相接,输出仍是上面的两段。 - 把第一段 Busy 改成
[08:00,18:00):它覆盖整个 Availability,输出为空。
图形不模拟时区、夏令时、Buffer、日历接口故障、限额或并发请求。这些条件不能通过移动图中挡片推断,必须回到 UserAvailabilityService 和 Booking 提交链路核对。
为什么选中 Slot 之后还要检查
RegularBookingService 会检查请求的时间与时长、相关限额,再调用 ensureAvailableUsers。后者重新取得可用性,显式关闭跳过日历 Busy 的选项,并判断整个请求是否落在可用区间内。显示 Slots 与提交之间,日历和其他 Booking 都可能变化,所以展示结果不能视为时段已经锁定。提交检查 · 重新检查入口 · ensureAvailableUsers · 完整区间检查
仓库还有带过期时间的 SelectedSlots 临时占用,查询 Slots 时会排除其他访客的有效占用;它与最终 Booking 是不同记录。这说明系统并非只做一次静态计算,但仍不能只凭存在临时占用表就认定所有并发路径都有原子锁。提交路径还明确使用 booking 模式绕过日历缓存。临时占用过滤 · 提交读取模式
在核对的正常创建路径里,数据库 Booking 先建立,之后才按条件创建外部日历或会议事件、发邮件、处理支付与 Webhook。局部数据库事务不能把这些外部系统一起纳入原子提交。因此,Booking 已存在、日历事件已创建、邮件已送达,是不同的结果;仅凭这条源码路径不能宣称所有并发竞态都已消除,或外部副作用绝对只执行一次。数据库写入 · 外部事件与通知 · 支付分支 · Webhook 分支
二次开发应分别验收这几层结果。例如用两个浏览器同时提交同一时间,确认最终有效 Booking 的数量;让日历接口或邮件服务失败,观察数据库状态、错误呈现和后续恢复。本文未执行这些真实服务测试,不把源码存在检查分支当作端到端保证。
自托管成本在哪里
Compose 比 README 的简短描述更复杂
固定提交的根 Compose 包含 PostgreSQL、Redis、Web、独立 API v2 与 Prisma Studio。直接运行整套服务会把 Studio 的 5555 端口和 Redis 端口一起映射出来;文件自己也提示,对外环境应移除 Studio。Web 可单独启动,API v2 不是“打开预约页面”必经的独立容器。Compose 服务定义
镜像字段没有固定 tag,数据库使用 postgres,Redis 使用 redis:latest。这不是一份可直接复现的固定版本部署清单。核查日的 Docker Hub 公开 tags 接口还返回了空列表;本次未执行镜像拉取,不能据此断言所有镜像入口都不可用,但不宜把 README 的旧 ARM tag 例子当作已验证安装路径。Docker Hub tags
本文建议先固定源码提交,在隔离环境构建和验收,再固定实际使用的镜像 digest。单纯运行 docker compose pull 不能证明拉到的程序对应本文分析版本。
配置不只是填一个数据库地址
| 配置 | 需要解决的问题 |
|---|---|
DATABASE_URL、DATABASE_DIRECT_URL | 连接应用数据库、执行迁移;Compose 还使用 POSTGRES_* 与 DATABASE_HOST 重新拼接连接串 |
NEXT_PUBLIC_WEBAPP_URL、NEXTAUTH_URL | 公开地址、登录跳转与 OAuth callback;构建和运行配置应一致 |
NEXTAUTH_SECRET、CALENDSO_ENCRYPTION_KEY | 会话与凭据相关加密;不能沿用默认占位值,密钥也属于备份恢复范围 |
GOOGLE_API_CREDENTIALS | 自己申请的 Google OAuth 客户端,不由仓库提供公共凭据 |
EMAIL_SERVER_*、EMAIL_FROM | 真实可投递的邮件配置,不能把本地测试 SMTP 当作通知已送达 |
NEXT_PUBLIC_VAPID_PUBLIC_KEY、VAPID_PRIVATE_KEY | Web Push 所需密钥 |
| API v2 的 Redis、JWT 和 Stripe 配置 | 独立服务的启动要求,与 Web 的配置不能混为一谈 |
字段来自固定提交的配置和排障说明。尤其要注意:Web 不启用 Stripe 可以不配置支付,但 API v2 的排障文档要求相应启动变量存在,并允许无支付场景使用占位值。占位值只能满足该启动条件,不能证明支付可用。环境变量样例 · API v2 排障
Google Calendar 的 callback 是 /api/integrations/googlecalendar/callback,Google 登录使用 /api/auth/callback/google;两个用途不同。需要在自己的 OAuth 客户端中配置真实域名,并按官方说明启用 Calendar API、配置授权范围和测试用户。源码保留集成框架,不代表第三方账户和授权审核已经准备好。Google 集成文档
按固定提交做第一次评估
下面是源码与配置检查命令,前置条件是 Git、Docker 和 Docker Compose 已安装。它不会生成真实凭据,也不会把服务暴露到公网;config --quiet 只能检查 Compose 配置,不能证明服务可启动。
git clone https://github.com/calcom/cal.diy.git
cd cal.diy
git checkout --detach 54343aa685ae8f33159d2f485ec4a57bad5c574a
cp .env.example .env
# 先按实际环境编辑 .env,再检查;不要提交真实值。
docker compose config --services
docker compose config --quiet
预计服务名包含 database、redis、calcom、calcom-api、studio。检查警告中的缺失变量,确认端口、数据库连接与构建参数,然后选择本次确实需要启动的服务。源码构建时,遵循根 packageManager、锁文件和 Dockerfile;README 的宽泛最低版本与子包中的旧声明不能替代这组固定输入。根 packageManager · Dockerfile
如果要从评估推进到可用服务,至少完成一次完整的个人预约:创建 Event Type、设置 Availability、连接日历、以另一位访客预约,再核对 Booking、日历事件、通知、取消和改期。更换访客时区后核对同一个绝对时间;并发与外部故障再单独测试。升级前备份数据库和所需密钥,并演练恢复,不能只保存容器文件。
维护状态与采用判断
截至核查日,分析的 main 提交日期为 2026-09-20;GitHub 的最新正式 Release 是 2026-03-01 发布的 v6.2.0,早于 4 月拆分。它证明仓库保留历史发布记录,不能证明当前社区主线已有对应、可复现的发布物。固定提交 · Releases
官方把社区维护与商业产品维护分开说明。选择时应检查拆分之后的提交、问题处理、CI 和实际可获得的构建产物,再判断是否愿意自己维护。本文没有从一个提交间隔或几个 CI 失败推导“项目已经停止维护”。
| 目标 | 本文建议 |
|---|---|
| 学习排期算法、Booking 业务和日历集成 | 值得读,先沿本文列出的核心链路进入 |
| 为自己搭一个预约页面,愿意维护服务器 | 可以在隔离环境试用,先验证自己的日历和邮件组合 |
| 需要团队轮转、组织权限、路由与自动跟进 | 当前范围不匹配,先评估具备这些能力的产品 |
| 希望获得托管、支持和稳定升级服务 | 优先评估相应商业服务,勿把源码许可当成服务承诺 |
| 改造成自己的预约产品 | 有代码基础,但要承担集成配置、升级、并发、恢复与安全工程成本 |
项目最值得借鉴的,是把 Availability、Busy、Slots 与 Booking 提交分开处理,以及把日历、会议等外部能力放在集成层。采用成本主要来自这些边界上的真实行为:时间是否算对、提交时是否仍可约、外部失败后业务如何恢复。
本次验证范围
本次读取官方公告和仓库文档,固定源码提交,交叉核对当前与旧标签的 LICENSE、Compose、配置变量和预约主线;通过 Context7 CLI 查询 Cal.diy 自身的部署与 Google 集成文档。交互图使用重建的区间模型:从固定源码抽取 subtract 原函数、仅去除 TypeScript 类型后,与教学模型对照了 1,900 组边界、滑块与预设输入,结果全部一致。该对照使用 UTC 时间戳,不覆盖 Dayjs 的时区处理;静态正文保留了默认输入、计算步骤、预期结果和简化边界。
本次未启动完整 Cal.diy、连接真实日历或 SMTP、创建真实 Booking,也未完成 Docker 镜像拉取、支付或并发验收。本文的功能判断来自官方资料与源码,部署建议是评估方法,不是该项目在某台服务器上已通过生产验证的记录。