[CODE]

不用資料庫的民宿後台:GitHub App + Octokit

引進 CMS 是在解錯問題。我給一家恆春民宿建了後台,透過 GitHub App 直接對部署分支發 commit,不需要獨立資料庫,Railway 90 秒完成部署。

2 min read AI 生成
github-api astro typescript web

這套後台的前提很簡單:廣告引擎需要材料,而材料要能即時更新。

我在恆春幫一家民宿建了一套數位基礎建設:Astro 靜態網站、GA4 接觸行為作為轉換代理、Google Ads 廣告投放引擎。引擎優化的方向是直訂轉換,而直訂轉換的前提是網站上的促銷內容是真實的。過期的優惠方案不只是無效——它吸引進來的詢問是不能兌現的。演算法學到的是壞訊號。

問題出在更新流程。前幾個月,每次內容更新的路徑是:WeChat 訊息進來,我開終端機,改 JSON,push,等 Railway build。順利的話 45 分鐘。週末進來的訊息,兩週後才改完。這不是「低維護成本的系統」,是把開發者當中介者的外部依賴,只是它的成本用延遲計算,不是用百分比計算。

為什麼 CMS 是錯的答案

我走過 CMS 這條路。帶業主登入新平台,說明內容欄位、集合,然後兩週後那個後台就成了手機裡不會打開的 app。

這些業主不是在排斥科技,而是非常務實地在用它。她腦子裡有的是「我要把暑假套裝方案的 banner 換掉」,沒有任何東西能對應到「內容集合」或「富文字欄位」。把這個落差填平需要持續的開發者支援,只是把原本的依賴搬到一個新介面,還多了一組要管理的帳號密碼,以及 CMS API 本身的失敗面。

內容從一開始就在 repo 裡。Persistence layer 本來就存在。缺的是一個能發 commit、但完全不需要知道那是什麼的寫入介面。

架構:一個 process,兩種模式

網站跑在 Railway,使用 @astrojs/node standalone adapter。面向訪客的頁面都標了 prerender = true,build 成靜態 HTML 直接上 Railway。Admin 路由不做 prerender,它們跑在同一個 Node process 裡,每個 request 都有 session 驗證。

output: 'server' 加上 marketing 頁面自己 opt-in prerender,讓 Railway 只需要跑一個 process,同時服務靜態頁面和 SSR 的後台。不需要第二個 service,不需要只為了後台存在的獨立部署目標。

認證:用她早就在用的基礎建設

Google OAuth 走 arctic,session 用 jose 簽發 7 天 JWT,存在 cookie 裡。不需要新帳號——業主本來就有 Gmail、Google 地圖商家頁面、Google Business Profile。管理物件在 Google 生態系裡的存在感,用的是同一個 Google 帳號,這個帳號同樣是開啟後台的鑰匙。

存取權由一份白名單 JSON 管控。這份檔案放在 repo 裡,對它的修改走跟其他所有後台操作一樣的路徑:commit 回 deploy 分支。新增或移除一個 email,就是一筆 commit。Railway env var 裡有一個種子 admin email,永遠有效,是一個 recovery hatch,UI 操作不可能把它撤銷。

每次 admin request 的驗證流程:讀 cookie,驗 JWT 簽名,查當前白名單。沒有 session store,沒有 database lookup。

GitHub 寫入:App,不是 token

用的不是 Personal Access Token。

是 GitHub App。這個差別比看起來重要。App installation token 短期有效且由 GitHub 自動輪換,不需要人工管理,也沒有從 deploy 設定洩漏的後患。App 以 repo 為單位安裝,寫入範圍在結構上就是有界的,不受 token 建立者當初的帳號權限影響。每筆 commit 帶 App 作為 committer、登入的 admin 的 Google email 作為 author。git log 看得到是誰按了儲存。

寫入的路徑在所有後台操作裡是一致的:從 deploy branch 讀目標 JSON 檔,取得當前 blob SHA,更新資料結構,帶著 SHA 寫回去。SHA 是 GitHub API 的樂觀並發鎖。SHA 過期就收 409。對單一 admin 的後台,這是可以忽略的 edge case。多人後台的情況下,這個設計決定系統有沒有正視「最後寫入覆蓋前一份」的問題。

圖文卡的原子提交

一張圖文型 PromoCard 需要三個檔案同時落地:原始 JPEG、伺服器端用 sharp 生成的 WebP 版本、以及更新後的 JSON 檔。三次循序的 createOrUpdateFileContents 呼叫,每次之間都有一個破碎狀態視窗——JSON 已經指向一張不存在的圖,或圖存在但沒有任何 card 指向它。

解法是 git data API。逐一把 blob 上傳取 SHA,在當前 branch tip 上組出新 tree,用那棵 tree 建一筆 commit,最後更新 branch ref。三個檔案在一筆 commit 裡原子落地,破碎狀態在結構上不可能存在——commit 要嘛完整,要嘛不發生。

刪除圖文卡走同樣的路,只是在 tree 裡把三個路徑的 sha 設成 null,一筆 commit 把 JPEG、WebP 和 JSON 裡的那筆 card entry 一起移除。

本機沙盒

storage 層有一個判斷:如果 BASE_URL 包含 localhost,讀寫直接走本機檔案系統,不打 GitHub API,不 commit。Vite HMR 接著改變,後台 dashboard 立刻反映。跑在 Railway 上的所有環境才透過 Octokit 走 GitHub。

目標分支由 Railway service URL 推導:production URL → main,staging URL → staging,dev URL → dev。同一份程式碼,三個環境,沒有任何設定要在部署間切換。

業主現在能控制的東西

首頁頂部的 PromoBanner 跑馬燈——中英雙語,有開始和結束日期,dashboard 顯示進行中、尚未開始、已過期三種狀態。首頁的 PromoCard 區塊——文字卡或圖文卡,中英雙語文案,CTA,季節日期區間,選填圖片上傳。白名單本身。

每筆操作自動生成 commit message,說明改了什麼。六個月的 banner 和 card 更新都在 git log 裡,不需要任何額外的稽核基礎建設就能重建異動紀錄。

開發者依賴消失了。替代它的是一份 commit log、一個 Railway build、九十秒。