SYSTEM ARCHITECTURE

hclane

新竹泳池預約系統 · 教練自動化代訂 · 完整架構文件

v0.1 · DRAFT 2026-05-13 CFA Gmail Auth Notion 後端 待 user 提供:泳池方系統 URL

1系統概念

新竹自潛 / 人魚教練每次訂池,要登入泳池方系統、看檔期、選水道、下訂——五個教練各做一輪,又怕撞時段。hclane 把這流程合成一頁日曆:同步檔期、衝突檢查、最後一步教練自己 click 送出。

目標 Goals
  • 教練在 hclane 一頁看完所有泳池檔期
  • 不用各自登入泳池方系統手動操作
  • 系統幫忙準備好下訂表單,最後一步教練 click 送出
  • 教練之間預約即時可見,避免撞時段
  • 使用 CFA Gmail,教練不用記新帳號
非目標 Non-goals
  • 不做付款流程(仍走泳池方系統原本付款)
  • 不做學員端:學員預約教練的功能不在本期
  • 不做全自動 race booking(搶秒級熱門時段)
  • 不取代泳池方系統,本系統是上層代理
  • 不存教練在泳池方的明碼密碼

2整體拓樸

三層架構:前端 ↔ 後端 ↔ Notion + 泳池系統。CFA 在最外層攔截所有流量。

Frontend
登入頁CFA
Dashboard 教練主頁
日曆檢視 所有泳池檔期
預約管理 我的預約
Settings 連結泳池帳號
Backend · Pages Functions
/api/auth session 驗證CFA
/api/sync 抓泳池檔期
/api/booking 衝突 + 下訂
/api/credentials 教練泳池憑證
/api/playwright 半自動下訂 worker
Data · External
Notion 5 DBs(單一真實來源)
泳池方系統外部
CF KV session 快取(短期)
CFA Gmail SSO
Service Token Pages → Notion

3使用者旅程

第一次使用 vs 回訪使用,流程差異主要在「連結泳池帳號」這一步。

第一次使用 First-time

1用 Gmail 透過 CFA 登入 hclane
2後端建立 Coach 記錄(從 Gmail 取 email/name)
3Settings 點「連結 OO 泳池」
4彈出泳池方登入頁,教練親手輸入帳密
5後端攔截 session cookies,加密存 PoolCredentials
6觸發第一次 sync,看到日曆

回訪使用 Returning

1瀏覽器打開 hclane,CFA 自動驗證
2進入 Dashboard,60s 節流:若上次 sync 超過 60 秒就背景拉新檔期
3看日曆,選想預約的時段 + 水道
4系統檢查 Bookings 衝突,鎖 Slot 為 pending
5後端 Playwright 用 cookies 預填泳池系統下訂表單
6截圖預覽,教練 click「確認送出」,後端按送出鈕,回寫 booking_id

4Notion 資料模型

五張 Notion DB 為單一真實來源。所有 API 讀寫都走 Notion API(內部 Pages Functions 用 service token)。

Coaches
教練主檔(每位教練 1 筆)
  • nametitle
  • emailemail
  • specialtyselect 自潛/人魚
  • credentialsrelation → PC
  • created_atdate
  • activecheckbox
PoolCredentials
教練在泳池方的加密 session(每教練 × 泳池 1 筆)
  • coachrelation
  • poolrelation
  • enc_sessionrich_text
  • ivtext
  • expires_atdate
  • last_useddate
Pools
泳池主檔(新竹幾個池,每個 1 筆)
  • nametitle
  • system_urlurl
  • login_typeselect
  • lane_countnumber
  • open_rulesrich_text
  • notesrich_text
Slots
可預約時段(同步來源 = 泳池系統,會有大量 row)
  • poolrelation
  • date_timedate(含時間)
  • lanenumber
  • statusselect
  • source_idtext(泳池系統 ID)
  • last_synceddate
Bookings
預約紀錄(hclane 自己掌控的狀態)
  • coachrelation
  • slotrelation
  • purposeselect 訓練/教學
  • studentsnumber
  • statusselect
  • pool_booking_idtext
  • created_atdate
  • error_logrich_text

狀態列舉

Slots.status available · pending(被某教練暫鎖)· booked · unavailable(泳池方標示不開放)
Bookings.status pending(鎖 slot 中)· awaiting_submit(系統準備好 Playwright form 等教練 click)· confirmed(泳池方回來 booking_id)· cancelled · failed

5API 路由

所有路徑都在 Cloudflare Pages Functions(functions/api/*.ts)。CFA 在 edge 攔截,到 Function 時 header 已含 Cf-Access-Authenticated-User-Email

Method
Path
用途
權限
GET/api/me取 / 建 Coach 記錄CFA
GET/api/pools列出所有泳池 + 教練連結狀態CFA
POST/api/credentials/link/start啟動連結 OO 泳池流程,回傳該泳池登入頁 URLCFA
POST/api/credentials/link/finish收教練填好的帳密 → 後端登入 → 加密寫 PoolCredentialsCFA
DELETE/api/credentials/:pool解除某泳池的連結CFA
GET/api/calendar?pool=&from=&to=讀 Notion Slots,回前端日曆CFA
POST/api/sync?pool=即時同步泳池檔期到 Slots(60s 節流,背景跑)CFA
GET/api/bookings列出我的預約CFA
POST/api/bookings/reserve鎖 Slot → 建 Booking(pending) → 衝突檢查CFA
POST/api/bookings/preparePlaywright 預填泳池系統表單,回截圖 + 確認 tokenCFA
POST/api/bookings/submit收教練確認 token → Playwright click 送出 → 寫回 booking_idCFA
DELETE/api/bookings/:id取消預約(含取消泳池系統訂單)CFA

6同步機制

三個觸發點 → 同一個 sync 邏輯。60 秒節流避免暴力打泳池方。

觸發點

A頁面打開時 — Dashboard 載入 → 後端檢查每個 pool 的 last_synced,超過 60s 就背景觸發
B手動「立即同步」按鈕 — 教練右上角按,跳過節流強制同步
C下訂前 — 預約流程內,submit 前再 sync 一次該 slot 確認還可用

同步流程

1FE頁面開啟,呼叫 POST /api/sync?pool=X
2BE查 Pools.last_synced — 若 ≤ 60s 前同步過,直接回 304 cached(前端用 Notion 既有 Slots)
3BE從 PoolCredentials 解密該教練的 session cookies
4EXT用 cookies 對泳池系統發 request(fetch / Playwright headless)拿可預約檔期 list
5DBDiff & Upsert:對 Notion Slots 做 batch upsert(依 source_id 為 key),找出新增 / 變更 / 消失的
6BE更新 Pools.last_synced,回 200 { added, updated, removed }
7FE收到 diff,更新日曆 UI(不需 reload)

7半自動下訂

分兩階段:prepare(系統做好 90% 工作)→ submit(教練最後 click)。確認 token 防止 CSRF / 跨單誤觸。

為什麼半自動而非全自動

① 泳池方系統若偵測「非人類自動下訂」可能 ban 教練帳號。半自動由教練本人按下最後送出鈕,行為對泳池系統來說是真實 click。
② 系統幫忙 90%:選泳池、選時段、選水道、填好表單;教練只看截圖然後 click confirm。沒有減少教練監督,但極大省時。
③ 出錯可以教練親眼看見再做決定(例如:泳池方臨時公告、價格變更)。

下訂流程(兩階段)

1教練在日曆 click 想預約的 slot,選用途(訓練/教學)+ 學員數,按「申請預約」
2FE呼叫 POST /api/bookings/reserve
3BE樂觀鎖:用 Notion API 把 Slot.status 從 available 改成 pending(如果失敗 = 已被別人鎖)
4DB建 Booking(status=pending),回傳 booking_id
5FE呼叫 POST /api/bookings/prepare 進入 prepare 階段
6BE啟動 Playwright headless,用教練 cookies 登入泳池系統 → 走到下訂頁 → 選好時段水道 → 填完表單 → 停在「送出」按鈕前
7BE截圖該頁面 + 生成一個一次性 confirm_token(5 分鐘內有效),存 Booking.error_log(其實是 prepare state)
8FE顯示截圖 + 「確認送出」大按鈕 + 「取消」按鈕
9教練看截圖確認沒問題 → 按「確認送出」
10FE呼叫 POST /api/bookings/submit 帶 confirm_token
11BE驗證 token → 喚回該 Playwright session → click「送出」 → 等泳池方回應
12DB收到 booking_id → Booking.status = confirmed / Slot.status = booked / pool_booking_id 寫入
13FE顯示成功 + 預約詳情

8衝突檢查

主要是兩個教練同時搶同一個 slot的情境。用悲觀鎖 + 5 分鐘逾時自動釋放。

樂觀鎖策略(用 Notion 屬性當鎖)

# 1. 嘗試把 Slot.status 從 available → pending
PATCH notion/pages/{slot_id}
  if current.status == "available":
    set status = "pending"
    set locked_by = coach_id
    set locked_at = now()
  else:
    return CONFLICT 409

# 2. Booking 建好後若 prepare/submit 失敗,釋放鎖
ON FAILURE:
  set Slot.status = "available"
  set locked_by = null
  delete Booking

# 3. 背景 worker(CF cron)每分鐘掃
WHERE Slot.status = "pending"
  AND locked_at < now() - 5min
  → 釋放:status = "available", locked_by = null
  → 也刪掉對應 Booking(status=pending)

不可能完全避免的衝突

因為泳池方系統是真實源頭,會有「hclane 顯示可訂,但實際 click submit 時被別人搶走」的小概率(教練 A 在 hclane 鎖了 slot,但其實泳池系統那邊某人直接登入該系統下訂;A 走到 submit 階段才發現)。處理方式:submit 失敗時清楚顯示「泳池方回應該時段已被預約」,自動 sync 一次,回到日曆讓教練重選。

9安全模型

三層信任:CFA (邊緣身份) → Pages Function (應用授權) → Notion service token (資料層)。教練的泳池憑證單獨加密。

CFA Gmail 登入

Cloudflare Access 政策:allow email ends with @gmail.com + 在 Coaches table active=true 的清單裡。Function 收到的 header 含已驗證的 email,不可偽造。

教練泳池憑證加密

用 Pages Function 環境變數 POOL_CREDS_KEY(AES-256-GCM key)加密 session cookies → 寫進 Notion 是 base64 ciphertext + IV,明碼從不離開記憶體。

Notion service token

Notion API token 只在 Function 環境變數,不暴露給前端。前端只能透過 /api/* 間接讀寫 Notion,所有授權都在後端二次檢查(不只信 CFA email)。

Confirm token (CSRF)

submit 階段用一次性 confirm_token(HMAC-signed,5 分鐘內有效)綁定 booking_id + coach email,避免別處連結誤觸下訂。

Rate limiting

同教練同泳池 sync 60s 節流;submit 每教練每分鐘最多 3 次;連結泳池帳號每天最多 5 次(避免被當暴力登入)。

Audit log

所有預約 / 取消 / 連結 / 解除動作寫進 Notion 的一個 Audit DB(or Booking.error_log),含時間 + email + IP + 結果。

10錯誤處理矩陣

每個錯誤情境都有明確 fallback 給教練的 UX。

CRED_EXPIRED 教練泳池 session 過期 偵測 sync / submit 失敗為 401 → Slot 鎖釋放 → 前端跳「請重新連結 OO 泳池」modal。
POOL_SYSTEM_DOWN 泳池方 5xx sync 失敗時用 Notion 快取繼續顯示,UI 標「上次同步時間 + 泳池系統暫時無法連線」。
NOTION_RATE_LIMIT Notion API 429 backoff retry × 3 次(exponential,最大 2s)→ 還是失敗就回 503 給前端,告知稍後重試。
SLOT_TAKEN_RACE reserve 時 Slot 已被別人鎖 回 409 + 提示「該時段剛被 ○○ 教練預約」,自動 refresh 日曆。
PREPARE_TIMEOUT Playwright 過久未完成 prepare 超過 30 秒 → 釋放 Slot、刪除 Booking、回 504 + 「泳池系統異常,請手動到泳池系統下訂或稍後再試」。
SUBMIT_REJECTED submit 時泳池方拒絕 Booking.status = failed + 釋放 Slot + 把泳池方錯誤訊息存 error_log + 提示教練看截圖。
CONFIRM_TOKEN_INVALID confirm_token 過期或被誤觸 回 403 + 釋放 Slot + 要求教練重新做 prepare。

11演進路線

分階段交付,每階段可獨立上線。

PHASE 1 · MVP

單泳池 + 一位教練

  • CFA Gmail 登入 + Coach 建檔
  • 單一泳池:完整 connect / sync / 預約 / 取消
  • Notion 5 張 DB 全建好
  • 半自動下訂 v1(簡單版 Playwright)
  • 目標:自己親手跑通一次預約 → 取消
PHASE 2 · 多教練

新增 4-5 位教練

  • 衝突檢查(樂觀鎖 + 自動釋放)
  • 共用日曆檢視(看到別教練已預約)
  • 每教練自己連結泳池憑證
  • Audit log + 通知(誰預約了什麼)
  • UI 打磨:載入態、錯誤態、空狀態
PHASE 3 · 多泳池

擴展到第 2 / 3 個泳池

  • 每個泳池有獨立 connector 模組
  • 日曆切換 / 並排檢視
  • 批次預約(同教練多時段一起送)
  • Telegram 推播教練自己的預約異動
PHASE 4 · 可選

學員端(如果需要)

  • 學員看教練可開課時段
  • 學員報名 → 教練收到通知 → 教練決定要不要 hclane 訂池
  • 串 LINE OA / 學員收費(Stripe / 街口)
  • 此期可能脫離 hclane scope 變獨立 product

12待釐清

這些等 user 提供答案,才能進入 Phase 1 實作。

Q1 · 泳池方系統是哪個?

名稱、URL、是不是政府場館預約 / 健身館預約系統 / 私人開發。決定 connector 策略(fetch vs Playwright)。

Q2 · 泳池系統的登入方式

email/password / 手機 OTP / Line login / Google OAuth?決定「連結泳池帳號」UX 設計。

Q3 · 泳池系統有沒有 API 或 mobile app?

如果有公開 API 最好;如果只有 web 那一定走 Playwright;mobile app 可能有 reverse engineering 的選項。

Q4 · 「水道」資料怎麼來?

泳池系統有以水道為單位的預約嗎?還是只訂「時段」不分水道?影響 Slots.lane 是否需要。

Q5 · 教練清單怎麼維護?

新教練加入的流程:user 手動到 Notion 加 row 開 active?還是教練自己用 Gmail 登入後自動加入 + 等 admin 啟用?

Q6 · 是否需要 admin 角色?

除了教練,是否有「管理者」(也許是 user 本人)可以看所有教練的預約、強制取消、處理糾紛?

Q7 · 付款怎麼處理?

本系統 Non-goal 是不做付款,但要確認泳池方付款是「下訂後現場付」「下訂時刷卡」「會員預扣」哪一種,影響 UX 提示。