cal.com、Calendly、zcal……預約 SaaS 的選擇一點也不少,而且大多數其實都相當不錯。對很多自由工作者來說,免費方案已經足夠涵蓋基本需求。問題在於:你才是產品(沒有什麼是真的免費),而你的客戶資料會存在某個你無法完全掌控、也無法完整稽核的地方。

我在另一個 SaaS 工具上遇到的一次異常,成了引爆點。僅僅因為某個第三方服務很常見、而且按月收費,就預設信任它,這件事不一定站得住腳。那次事件已經足以讓我重新檢視這個網站所依賴的所有外部服務——那些其實可以自行架設、功能又很簡單的服務;而由 Calendly 驅動的預約小工具,就是其中之一。

我並不是對 Calendly 有什麼意見。它運作得很好。只是結構上的摩擦一直都在:為了顯示可預約時段並記錄選擇,卻得付一筆看似無關緊要的訂閱費;一個技術上沒什麼特別之處的元件,卻要硬性依賴第三方;而客製化又被限制在廠商設定專案所能提供的範圍內——一旦需求超出那個盒子,就再也走不下去。更重要的是,還有一個整合上的限制,比前面任何一點都更關鍵:這個網站是用 Astro 建的,設計目標就是產出輕量的靜態頁面,刻意避免第三方腳本與依賴帶來的負擔——這和嵌入一個 SaaS 小工具所代表的做法,正好完全相反。

所以問題就變成:有沒有一個自架替代方案,能在不付月費、也不把核心商業功能(讓別人跟我預約通話)交給外部廠商的前提下,達到相同體驗?這篇文章就是那段搜尋過程、最後產生的程式碼稽核,以及正式上線的紀錄。

市場概況

有四個自架方案特別突出,算得上是真正可比的選項——不是只是在別人 API 上包一層 UI,也不是把內部排程工具硬拿來當公開前台 UX 的後補方案。

CloudMeet — Svelte + TypeScript,部署在 Cloudflare Pages/Workers/D1,上免費方案很友善。MIT 授權。預約 UX 乾淨俐落。單一維護者,約 490 顆星、37 個 commits——很年輕,還沒有足夠的實績能讓人完全放心。

Cal.diy — cal.com 預約引擎的社群分支,於 2026 年 4 月 cal.com 將核心產品改為閉源後出現;官方理由是,AI 輔助程式碼掃描會對其公開倉庫帶來安全風險。MIT 授權,由前 cal.com 實習生維護。完整的排程引擎、應用商店整合、Stripe/PayPal 付款支援都有保留下來。紙面上功能最完整。也同時是最年輕的獨立社群專案——他們自己的文件到現在仍然會提醒,除非接受一些前提,否則不建議直接用在正式環境。

booking-calendar — React + TypeScript + Bun + SQLite,單一管理者設計,原生雙向 CalDAV 同步,而不是綁死 Google/Outlook。架構乾淨(透過 TypeORM 做 repository/service/entity 分層)。設計輕量、可攜性高。預約 UI 是可捲動的時段清單——功能上沒問題,但離商業排程工具的精緻度還差很遠。

Easy!Appointments — PHP/CodeIgniter + MySQL,十年持續開發,3000+ 顆星,還有一個多年來一直存在的付費方案。四者之中最經得起實戰考驗,也最接近 Calendly 本身的預約 UX:月曆檢視、多步驟精靈流程。

我實際想最佳化的是:可與框架無關地整合(不在公開網站本體上跑 PHP)、預約 UX 的品質、原生 GDPR 同意處理,以及足夠的生產品質,能直接放到真實潛在客戶面前,而不是只拿來當實驗性專案。

篩選過程

CloudMeet 和 Cal.diy 很早就被淘汰了,背後原因其實一樣:不是發現了明確缺陷,而是可供生產使用的實績還不夠。CloudMeet 的單一維護者模式和短暫 commit 歷史,讓它太像一場賭注。Cal.diy 自己的文件在上線三個月後,對正式環境可用性仍然保留很多但書。不管它們最後會不會變成嚴肅的競爭者,還是只是一個曇花一現的專案,那都是之後才要回答的問題。

老實說,這種判斷依據本身就值得坦白:這是根據名聲和專案年齡做的決定——而這正是本文後面要反對的那種偷懶捷徑。若要像下面兩個最終候選方案那樣,對四個程式碼庫都做同樣深度的稽核,在時間上並不現實,所以 CloudMeet 和 Cal.diy 只做了較輕量的檢視——用年輕專案的經驗法則,而不是直接讀完整原始碼。這在方法上確實有缺口,不只是順帶一提的警語而已。

最後只剩 booking-calendar 和 Easy!Appointments。而這裡就是 UX 差距決定勝負的地方:booking-calendar 的公開預約頁面,只是一個平淡的可捲動清單,列著像 Monday, February 23, 2026 at 08:00 AM 這樣的專案——資訊準確,但視覺上距離 Calendly 讓人習慣的樣子差了十萬八千里。沒有月曆檢視,也沒有分階段流程,只有文字讓你往下滑。

我曾短暫考慮把兩者拼在一起——例如用 CloudMeet 的前端接 booking-calendar 的 CalDAV 後端,或類似的做法。但實務上不可行:執行環境不同(Cloudflare Workers/D1 對 Bun/SQLite),沒有共用 API 契約,也沒有共用資料模型。把兩個不相容的 stack 硬縫在一起,通常只會比選一個再調整它更麻煩。最後我還是回到職責分離的原則:挑前台 UX 較好的工具,如果之後真的需要原生 CalDAV 同步,那再另外做第二個獨立工具——不是硬把兩者合併。

Easy!Appointments 靠 UX 勝出。接下來才是做出正式生產決策真正該看的部分。

稽核:兩個剩下的候選方案,都有 TOCTOU 競態條件

在決定採用哪一個之前,我想先確認一件事:如果兩個訪客幾乎同一時間點擊同一個時段,會發生什麼?這不是理論問題——這種 bug 在單人開發時幾乎不會出現,但只要預約連結稍微被分享得廣一點,就很容易爆炸(例如電子報大量發送、LinkedIn 貼文、或固定時間一次開放很多新時段)。

這是典型的 TOCTOU(time-of-check to time-of-use)競態條件:程式先檢查某時段是否可用,再寫入預約;但在檢查與寫入之間,沒有任何機制阻止另一個並發請求做完全相同的檢查。若沒有跨越檢查與寫入的鎖,兩個請求都可能在任何一方提交前,先得出「可預約」的結論。

booking-calendar

基於 TypeORM,採 repository 模式,其他部分的職責分離也很乾淨。重疊檢查如下:

async hasOverlapInSlot(
    slotId: number,
    startAt: string,
    endAt: string,
    manager?: EntityManager,
): Promise<boolean> {
    const count = await this.repo(manager)
        .createQueryBuilder("a")
        .where("a.slot_id = :slotId", { slotId })
        .andWhere("a.canceled_at IS NULL")
        .andWhere("a.status != 'rejected'")
        .andWhere("NOT (a.end_at <= :startAt OR a.start_at >= :endAt)", {
            startAt,
            endAt,
        })
        .getCount();

    return count > 0;
}

它是在交易內被呼叫的(AppDataSource.transaction),但沒有 .setLock("pessimistic_write"),在 schema 層級也沒有排他性約束。對 SQLite 來說,這個問題根本不會浮現:引擎本來就會把寫入序列化,一次只處理一個。這是儲存引擎自帶的免費安全網——但不是應用程式真的有落實保護。這個專案的架構還明確預留了之後透過 TypeORM 的 driver 抽象,從 SQLite 移轉到 Postgres 或 MySQL 的路徑(type: "sqlite"type: "postgres",紙面上很簡單)。而那正是會把這張安全網拿掉的移轉:在 READ COMMITTED 隔離等級下,兩個並發交易都可能在任一方的 INSERT 尚未提交之前,先讀到「沒有重疊」。

我也確認了 CalDAV 同步層,發現還有第二個、獨立的競態問題,這次是跟外部行事曆,而不是本地資料庫競爭:

private async getCachedBusyIntervals(startAt: string, endAt: string): BusyInterval[] | null {
    const cache = CalDAVService.busyIntervalCache;
    if (!cache || cache.expires_at <= Date.now()) {
        return null;
    }
    if (startAt < cache.start_at || endAt > cache.end_at) {
        return null;
    }
    return cache.intervals.filter(
        (interval) => !(interval.end_at <= startAt || interval.start_at >= endAt),
    );
}

這是一個全程序範圍的靜態快取,帶 TTL,且只有在成功寫入之後才會失效。兩個預約若在幾秒內接連到來、而且都落在快取有效期間內,就可能先讀到同一份「空閒」快照,然後才各自對外部行事曆完成寫入——這和前面完全一樣的 TOCTOU 模式,只是 truth source 換成了外部 CalDAV 伺服器,而不是本地資料庫。

Easy!Appointments

十年實際上線、3000+ GitHub stars、以及一個已存在多年的付費方案。我的初步假設是:實際流量越多,這種 bug 越有機會早就被踩過並修掉。但讀完程式碼後,這個假設站不住腳。

公開預約流程如下:

// 在把預約登記到資料庫之前,先檢查可用性。
$appointment['id_users_provider'] = $this->check_datetime_availability();

if (!$appointment['id_users_provider']) {
    throw new RuntimeException(lang('requested_hour_is_unavailable'));
}

// ... 客戶查找/建立、GDPR 同意紀錄、Jitsi 連結產生 ...

$appointment_id = $this->appointments_model->save($appointment);

在檢查和寫入之間夾了好幾個彼此無關的操作——這裡的競態窗口比 booking-calendar 更大,因為至少那邊的檢查與插入還是在同一個交易裡。至於真正的插入:

protected function insert(array $appointment): int
{
    $appointment['book_datetime'] = date('Y-m-d H:i:s');
    $appointment['create_datetime'] = date('Y-m-d H:i:s');
    $appointment['update_datetime'] = date('Y-m-d H:i:s');
    $appointment['hash'] = random_string('alnum', 12);

    if (!$this->db->insert('appointments', $appointment)) {
        throw new RuntimeException('Could not insert appointment.');
    }

    return $this->db->insert_id();
}

沒有交易、沒有鎖、沒有唯一約束。check_datetime_availability() 的 docblock 還寫著:

「有可能兩位或多位客戶同時選到同一個預約日期和時間。系統不會允許這種情況發生。」

這是文件化的意圖,不是程式實際做的事。

有趣的地方

Appointments_model 裡其實有一個方法,能正確做重疊檢查,查詢也寫得對:

public function has_provider_conflict(
    int $provider_id,
    string $start_datetime,
    string $end_datetime,
    ?int $exclude_appointment_id = null,
): bool {
    $this->db->select('id')->from('appointments')->where('id_users_provider', $provider_id);

    if ($exclude_appointment_id) {
        $this->db->where('id !=', $exclude_appointment_id);
    }

    // 重疊條件:(既有開始時間 < 新結束時間)且(既有結束時間 > 新開始時間)
    return $this->db
        ->group_start()
        ->where('start_datetime <', $end_datetime)
        ->where('end_datetime >', $start_datetime)
        ->group_end()
        ->get()
        ->num_rows() > 0;
}

只是它從來沒有在公開預約流程中被呼叫。正確的基礎方法其實就在程式庫裡,只是沒有接到真正需要它的地方。

結論

就這個特定問題而言,兩個專案都不能說誰比較「穩健」——它們都存在同樣的設計缺口,與相對成熟度無關。名聲、年齡、商業付費方案:這些都只是合理的先驗機率,不是證據。要知道一個開源專案是否真的防住這類 bug,唯一的方法就是去讀實際在工作的程式碼,而不是相信註解裡宣稱的東西。

順帶一提,Easy!Appointments 這邊的修補其實很接近可直接套用——正確的原語(has_provider_conflict)早就存在,只差把它接進去,並在檢查與寫入外圍加上鎖,而不是重新設計一套:

$lock_name = "provider_{$provider_id}_booking";

if (!$this->db->query("SELECT GET_LOCK(?, 10)", [$lock_name])->row()->{"GET_LOCK(?, 10)"}) {
    throw new RuntimeException('Could not acquire booking lock.');
}

try {
    if ($this->appointments_model->has_provider_conflict(
            $appointment['id_users_provider'],
            $appointment['start_datetime'],
            $appointment['end_datetime']
        )) {
        throw new RuntimeException(lang('requested_hour_is_unavailable'));
    }
    $appointment_id = $this->appointments_model->save($appointment);
} finally {
    $this->db->query("SELECT RELEASE_LOCK(?)", [$lock_name]);
}

這裡用 GET_LOCK,而不是 schema 層級的排他約束,因為 MySQL 沒有像 Postgres 的 EXCLUDE USING gist 這種對應機制——也就是說,資料庫層級沒有宣告式方法可以直接表達「同一位服務提供者不能有兩個重疊的時間區間」。鎖的 key 是按服務提供者範圍劃分(provider_{id}_booking),而不是全域鎖,所以兩個不同服務提供者的預約不會在同一時間互相阻塞。RELEASE_LOCK 放在 finally 裡,確保即使寫入失敗,也不會讓鎖一直卡到 timeout 才釋放。

這個修補還默默依賴一個前提:資料庫連線不能是 persistent。GET_LOCK 是綁 MySQL session,不是綁 PHP request——它會跟著連線生、跟著連線死。一般非 persistent 連線(CodeIgniter 預設)下這點是沒問題的:每個 request 都有自己的連線,request 結束時鎖就會乾淨地消失,即使發生未捕捉的 fatal error 也是如此。但如果啟用了 pconnect,讓連線在不同 request 之間被池化重用,情況就變得不對了:finally 裡的 RELEASE_LOCK 可能會釋放掉同一條被回收連線上、另一個 request 剛拿到的鎖,或者某把鎖甚至可能比取得它的 request 活得更久。這件事值得在 patch 裡明講,而不是默默假設,因為 has_provider_conflict() 或周邊程式碼本身都看不出這一點。

這個修補最後沒有真的部署到我的環境裡——上面那個 LOCK/VERIFY/WRITE/UNLOCK 骨架已經很接近可上線,但在我把它算成完成之前,還需要針對 ANY_PROVIDER 分支做壓力測試覆蓋(那段程式會尋找任何可用的服務提供者——鎖也必須包住這個搜尋,否則兩個「任意提供者」請求仍然可能同時落到同一位提供者/同一個時段上)。之後也許值得向上游提 PR。

正式上線:單純嵌入,卻被意外擋下

整合本身很簡單:Easy!Appointments 跑在自己的子網域上,聯絡頁只要放一個指向它的 <iframe>。公開的 Astro 網站本體完全不碰 PHP——這正是當初要切開的原因。

部署後第一次測試,iframe 沒有渲染。主控台錯誤如下:

Refused to display 'https://cal.example.com/' in a frame because it set
'X-Frame-Options' to 'sameorigin'.

第一個猜測,錯了

考慮到伺服器架構(網站前面有一層 hosting 控制台與反向代理),最先懷疑的自然是那層,而不是應用本身。先試著在 .htaccess 層級取消這個 header:

<IfModule mod_headers.c>
    Header always unset X-Frame-Options
    Header always set Content-Security-Policy "frame-ancestors 'self' https://backstage.click"
</IfModule>

沒用。用 curl -I 看,header 還是照樣出現。

真正原因

直接去 grep Easy!Appointments 原始碼就找到了:

./application/hooks/security_headers.php:    header('X-Frame-Options: SAMEORIGIN');
./application/config/routes.php:header('X-Frame-Options: SAMEORIGIN');

這個應用本身就在 PHP 裡、而且是在自己啟動流程的兩個不同位置,直接設定了這個 header——一個硬編碼的防 clickjacking 預設值。對管理後台來說這很合理,但它被不加區分地套用到所有路由,包括原本就打算被嵌入到別處的公開預約頁面。.htaccess 裡的 Header unset 是在 web server 的 response table 層級先執行,早於 PHP 程式;而之後腳本自己呼叫的 header() 則會直接覆寫它。這種情況下,.htaccess 無論在 Apache、Nginx 還是 OpenLiteSpeed 上,都不可能成功;只要輸出還沒開始,PHP 對自己的 header 就有最後發言權。

真正可行的修補,是把兩個呼叫點都改掉,並且只針對 booking controller 限定作用,讓管理後台仍維持預設保護:

$CI =& get_instance();
if (get_class($CI) === 'Booking') {
    header("Content-Security-Policy: frame-ancestors 'self' https://backstage.click");
} else {
    header('X-Frame-Options: SAMEORIGIN');
}

這裡是用已實例化的 controller 類別,而不是去解析 $_SERVER['REQUEST_URI']——這次安裝沒有啟用乾淨 URL(booking URL 裡會出現 index.php),所以如果用像 strpos($uri, '/booking') === 0 這種天真的檢查,會默默對不上。

另外有一件事值得提醒:如果你也要改這兩個檔案,這些都是核心應用程式碼,不是外掛層。未來只要 git pull 一次,或 Docker image 重新建置一次,這個修補就會被靜悄悄覆蓋掉。比較好的做法是把它保留成一個有版本管理的 patch 檔(先 git diff 前後差異),升級後再重套,而不是下一次容器重建時就把它弄丟。

結果

客戶資料(姓名、電子郵件、會議原因)留在我能控制的基礎設施上,而不是第三方那邊;而且網站的初始頁面重量也沒有增加——這個功能沒有額外加入任何第三方腳本。成本其實不是這裡的主要驅動因素;對很多自由工作者來說,免費方案已經足夠,包括我自己也是。我要換回來的不是訂閱費,而是那一段由廠商決定「基本需求」是什麼、以及潛在客戶的聯絡資料最後會跑去哪裡的控制權。

值得注意的是:四個工具裡到底哪一個適合,完全取決於某個特定商業情境需要什麼,而不是誰在這裡「贏了」。例如 Easy!Appointments 其實也很適合作為 Bookly 的自架替代方案,給那些在跑 WordPress、又想停掉預約外掛訂閱的人使用——起點不同,但核心問題一樣。

不過,工具替換本身其實不是重點。這只是我一直反覆回到的一種模式:只要合理,就優先選擇自架,不要讓「很常見」、「已經十年了」或「有付費方案」這些詞,取代了真正去檢查。名聲只是先驗,不是判決。這篇文章裡最清楚的例子就是 Easy!Appointments 的稽核——十年生產流量、商業方案、數千顆星星,結果最重要的程式路徑裡就躺著一個競態條件,而正確修法早就寫在程式庫其他地方,卻根本沒被呼叫到。成熟度沒有抓到它,讀程式碼才抓到。

但還有一個更值得停下來想的地方:這個修補在我的部署上其實也還沒真的上線。上面雖然已經寫出草案,但還沒完成,卡在我尚未做的壓力測試。這代表目前正在跑這個網站預約頁面的實例,就我所知,仍然暴露在整篇文章在談的那個 TOCTOU 視窗裡。自架讓我看見了這個缺口——如果用 Calendly,這個缺口會被廠商的 SLA 藏起來,我也無從得知真相。自架沒有自動幫我修好它。那是另一份工作,而且就算先發現了 bug,也不會因此獲得跳過修補的特權。自架給你的是控制權。安全性仍然得靠自己花時間、自己動手去建立——而在那個 patch 真的落地之前,負責把它做完的人,不是這篇文章的說法,而是我待辦清單上的一項任務。

這四個方案裡,它既不是最快的,也不是最直覺的。它需要一次真正的專案比較、一次正式上線後才出現的除錯迂迴,以及花時間讀原始碼,而不是只信 README。這才是自己架設基礎設施、而不是向別人租用基礎設施的真實代價。


原文出處:https://dev.to/pascal_cescato_692b7a8a20/what-replacing-calendly-taught-me-about-trusting-open-source-540a


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝10   ❤️4
304
🥈
我愛JS
📝1   💬4   ❤️1
58
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登