跳至正文
App — Cal.diy 项目调研:个人预约、自托管边界与时段计算

Cal.diy 项目调研:个人预约、自托管边界与时段计算

Cal.diy 官方仓库

AI 参与说明(Agent:Codex):本文依据 Cal.diy 官方仓库、固定提交源码、官方拆分公告与部署文档辅助调研,并使用项目 Anatomy 制作区间计算交互、使用 humanizer-zh 编辑中文正文。资料核对于 2026-10-15,源码基线为 54343aa685ae8f33159d2f485ec4a57bad5c574a。采用建议属于本文判断;源码核验、教学模型与真实部署的验证范围分别说明。运行记录:模型 gpt-6.1-sol,reasoning effort ultra,执行入口 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 APINestJS;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 分钟预约如何对齐、提前多久才可约,还要看后续条件。

ANATOMY · FIG. 01

Busy 怎样把一段开放时间切开

四条时间槽使用同一把尺。空隙就是被 Busy 排除的部分,亮色片段是差集。

区间排版尺 · 固定 UTC 单日08:00 → 18:00
四槽时间区间比较尺从后向前:开放时间 09:00–17:00;Busy A 10:00–11:00;Busy B 14:00–15:30;处理前 2 段 Busy 后的剩余区间为 09:00–10:00、11:00–14:00、15:30–17:00。所有区间按相同时间比例切出实物条片,结果不是最终可预约 Slots。
后 → 前:开放 · A · B · 剩余3 段 · 330 分钟
每格 15 分钟,长刻度为 1 小时。结果槽中保留的条片与原始区间严格对齐;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
14:00
90

按开始时间排序,取前 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_KEYWeb 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 配置,不能证明服务可启动。

sh
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 镜像拉取、支付或并发验收。本文的功能判断来自官方资料与源码,部署建议是评估方法,不是该项目在某台服务器上已通过生产验证的记录。

博客助手

正在打开博客助手…