Files
a620906209 975b56ca54 feat:實作預約系統 — 時段管理、預約生命週期與前端整合
後端:
- 新增 course_schedules / bookings migration(含索引)
- BookingStatus / ScheduleStatus PHP BackedEnum
- CourseSchedule / Booking Model(七狀態機 VALID_TRANSITIONS)
- ScheduleController、ProviderBookingController、MemberBookingController
- 雙層名額驗證(API 層快速失敗 + DB lockForUpdate 防超賣)
- 24h 取消截止、pending 不佔位設計
- ExpirePendingBookings(每小時)/ CompleteFinishedBookings(每日)Scheduler
- Docker cron 配置、CACHE_STORE 改為 file 修正 502

前端:
- 課程詳情頁加入時段選擇與預約流程
- 我的預約頁(展開式卡片、狀態說明、連結課程詳情)
- Coach 時段管理(上午/下午時間選擇器、新課程引導)
- Coach 預約管理(依課程分組、待確認徽章)
- Navbar 新增「我的預約」與「時段/預約管理」入口
- 密碼格式提示與即時比對

OpenSpec:
- booking-system change 歸檔至 archive/2026-05-12-booking-system
- 新增 specs/course-scheduling 與 specs/booking-lifecycle 主規格

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 00:24:51 +08:00

16 KiB
Raw Permalink Blame History

Context

CFDivePlatform 是 Laravel 11 + Vue 3 的潛水課程媒合平台。目前 Member 只能瀏覽課程,無法完成預約。需要在現有 Sanctum 認證體系上新增預約流程,並引入 Laravel Scheduler 處理自動狀態轉換。

現有關鍵資料模型:users(三角色)、diving_offers(含 priceprovider_id)。本次不修改任何現有資料表。

Goals / Non-Goals

Goals:

  • Provider 可建立/管理開課時段(course_schedules
  • Member 可對時段送出預約(bookings
  • 七狀態狀態機,含自動過期(48h)與自動完成(課程後)
  • 前後端完整串接

Non-Goals:

  • 金流整合(payment 欄位預留但不串接)
  • 推播通知(Email/SMS
  • 管理員預約管理介面(Admin Panel 待後續)
  • 退款流程(取消後僅改狀態,不觸發退款)

Decisions

決策一:狀態機實作方式 — PHP BackedEnum + string 欄位

選擇DB 欄位用 string(非 MySQL ENUM),應用層用 PHP BackedEnum 管理合法值。

// app/Enums/BookingStatus.php
enum BookingStatus: string {
    case Pending           = 'pending';
    case Confirmed         = 'confirmed';
    case Completed         = 'completed';
    case Rejected          = 'rejected';
    case Expired           = 'expired';
    case MemberCancelled   = 'member_cancelled';
    case ProviderCancelled = 'provider_cancelled';
}

// app/Enums/ScheduleStatus.php
enum ScheduleStatus: string {
    case Open      = 'open';
    case Full      = 'full';
    case Cancelled = 'cancelled';
}

Migration 使用 $table->string('status')->default('pending')Model 用 $casts = ['status' => BookingStatus::class]

Booking Model 定義 VALID_TRANSITIONS 常數,transition 前統一驗證合法性:

const VALID_TRANSITIONS = [
    'pending'            => ['confirmed', 'rejected', 'expired', 'member_cancelled'],
    'confirmed'          => ['completed', 'member_cancelled', 'provider_cancelled'],
    'completed'          => [],
    'rejected'           => [],
    'expired'            => [],
    'member_cancelled'   => [],
    'provider_cancelled' => [],
];

理由MySQL ENUM 加欄位值需要 ALTER TABLE(鎖表),在大資料量下有停機風險。用 string 欄位,未來加狀態只需改 PHP Enum,零 Migration。PHP 8.1 BackedEnum 提供 IDE 自動補全與型別安全,兼顧可維護性。

放棄:DB ENUM → 維護成本高,每次加狀態都要 Migration;引入狀態機套件 → 過度設計,transition 數量不值得。


決策二:人數計數 — DB 欄位 + 悲觀鎖

選擇course_schedules.current_participants 實體欄位,更新時用 lockForUpdate()

DB::transaction(function () use ($booking, $schedule) {
    $schedule = CourseSchedule::lockForUpdate()->find($schedule->id);
    // 驗證剩餘名額...
    $schedule->increment('current_participants', $booking->participants);
});

理由:比每次 COUNT(bookings) 查詢效率高;悲觀鎖防止超賣 race condition。

放棄:樂觀鎖(version column)→ 需要 retry 邏輯,複雜度不值得。


決策三:Scheduler 頻率

Job 頻率 原因
ExpirePendingBookings 每小時 過期精確度到小時即可
CompleteFinishedBookings 每日凌晨 課程完成以「日」為單位

決策四:價格快照

選擇:建立 Booking 時將 diving_offer.price × participants 存入 bookings.total_price

理由:Provider 日後調整課程價格不應影響已建立的預約;金流整合時直接使用此欄位。


決策五:取消時段的 Cascade 實作

選擇:在 ScheduleController::destroy() 內用單一 DB transaction 同時更新時段與相關 Booking。

DB::transaction(function () use ($schedule) {
    $schedule->update(['status' => ScheduleStatus::Cancelled]);
    $schedule->bookings()
        ->whereIn('status', [BookingStatus::Pending, BookingStatus::Confirmed])
        ->update(['status' => BookingStatus::ProviderCancelled]);
});

理由Booking cascade 必須與時段取消原子性完成,避免時段已取消但 Booking 仍掛 confirmed 的髒狀態。批次 update 不逐筆觸發 Model Event,效率優先(MVP 規模不需要逐筆通知)。

放棄:逐筆呼叫 $booking->transitionTo(ProviderCancelled) → 在大量預約時效率差,且 Model Event 觸發通知屬於未來功能。


決策六:Member 取消截止時間

選擇:課程開始前 24 小時為截止點,計算方式為 $schedule->scheduled_date + $schedule->start_timeCarbon datetime)。

$courseStart = Carbon::parse($schedule->scheduled_date . ' ' . $schedule->start_time);
if (now()->diffInHours($courseStart, false) < 24) {
    return response()->json(['status' => false, 'message' => '距課程開始不足 24 小時,無法取消'], 422);
}

理由:潛水課程有實際的人力與設備準備成本,24h 截止是業界常見標準。pending 狀態同樣受此限制,避免 Member 在課程即將開始時仍送出再取消的操作。

放棄:只限制 confirmed 取消、pending 不限 → 業務上應一致處理,24h 截止對兩種狀態同樣適用。


決策七:Participants 驗證時機

選擇:分兩個階段各做一次名額驗證。

階段 A — 建立 pending 時(早期拒絕,current_participants = 已確認人數)

Layer 1 (Controller,進 transaction 前):
    $remaining = $schedule->max_participants - $schedule->current_participants;
    if ($participants > $remaining) return 422;  // 連確認的名額都滿了,早期拒絕

Layer 2 (transaction 內,lockForUpdate 後):
    $schedule = CourseSchedule::lockForUpdate()->find($id);
    $remaining = $schedule->max_participants - $schedule->current_participants;
    if ($participants > $remaining) throw new InsufficientSlotsException();
    // 通過後只建立 Booking,不 incrementpending 不佔位)

階段 B — Provider 確認時(真正佔位,lockForUpdate + increment

DB::transaction(function () use ($booking) {
    $schedule = CourseSchedule::lockForUpdate()->find($booking->schedule_id);
    $remaining = $schedule->max_participants - $schedule->current_participants;
    if ($booking->participants > $remaining) throw new InsufficientSlotsException();
    $booking->update(['status' => 'confirmed']);
    $schedule->increment('current_participants', $booking->participants);
    $schedule->refresh();
    if ($schedule->current_participants >= $schedule->max_participants) {
        $schedule->update(['status' => 'full']);
    }
});

理由current_participants 只計算 confirmed 人數。pending 是「申請」不是「保留」,Provider 確認時才真正佔位。階段 A 的早期拒絕防止在所有 confirmed 額度滿後仍接受新 pending;階段 B 的 lockForUpdate 是真正防超賣機制。


決策八:重複預約防護 — 應用層 transaction 內檢查

選擇:不使用 DB UNIQUE constraint,改在建立 Booking 的 DB transaction 內執行重複性檢查。

DB::transaction(function () use ($memberId, $scheduleId, ...) {
    // 在 lockForUpdate 取得 schedule 的同一 transaction 內檢查
    $duplicate = Booking::where('member_id', $memberId)
        ->where('schedule_id', $scheduleId)
        ->whereIn('status', [BookingStatus::Pending, BookingStatus::Confirmed])
        ->exists();
    if ($duplicate) {
        throw new DuplicateBookingException();
    }
    // ... 建立 Booking
});

理由DB UNIQUE(member_id, schedule_id) 會阻止 Member 在取消後重新預約同一時段(如原 pending 取消後想改約),業務上不合理。應用層只檢查活躍狀態(pending/confirmed),允許對同一時段在取消後再次預約。將檢查放在 transaction 內確保與建立操作原子性,避免 TOCTOU race condition。

放棄DB UNIQUE constraint → 語意過強,阻擋合法的取消後重訂;Controller 層(transaction 外)檢查 → 有 TOCTOU 風險。


決策九:公開時段 API 的明確過濾條件

選擇GET /api/diving-offers/{id}/schedules 回傳條件為:

WHERE diving_offer_id = :id
  AND status = 'open'
  AND scheduled_date >= CURDATE()
ORDER BY scheduled_date ASC, start_time ASC

fullcancelled 時段不回傳;過去日期時段不回傳。

理由:只回傳 open 確保 Member 看到的全是可預約的時段,前端不需要再做客戶端過濾。full 不顯示(MVP 不做候補名單功能),scheduled_date < today 的時段對 Member 無意義。

放棄:回傳 open + full(前端再過濾)→ 增加前端複雜度,且 full 時段對無候補功能的 MVP 無用。


決策十:Coach / Provider 命名慣例

現況:codebase 存在兩套命名:前端用 coach,後端 API 和 DB 用 provider。這是前期開發的歷史遺留,本次不重構。

決策

  • 前端路由:維持 /coach/*(已存在,不破壞現有 URL
  • 後端 API 路由:統一用 /provider/*(與 DB role 欄位值一致)
  • DB users.role 欄位值'provider'PHP 端 isProvider() 方法判斷)
  • 本次新增程式碼:後端一律用 provider 命名(Controller、Policy、Middleware);前端新增頁面放在 /coach/* 路由下

新加入開發者應知道:前端 /coach/* = 後端 /api/provider/* = DB role = 'provider',三者指同一群用戶。


決策十一:審計追蹤(已知限制)

現況bookings 表只有 created_at/updated_at,無法從 DB 層直接查「何時確認」「何時取消」。

MVP 決策:接受此限制。updated_at 可作為最後一次狀態變更的時間戳,精確度足夠 MVP 使用。

未來(金流整合前必須處理):加入 booking_status_logs 歷史表,記錄每次 status transition 的 from_statusto_statuschanged_byuser_id)、changed_at。屆時新增一個 Migration 即可,不影響現有 bookings 結構。


決策十三:名額回收與時段狀態自動轉換

current_participants 增減規則(只計算 confirmed 人數):

觸發動作 current_participants 說明
pending → confirmedProvider 確認) +participants 確認時才佔位
confirmed → member_cancelled -participants Member 取消,釋放名額
confirmed → provider_cancelled -participants Provider 取消時段或單筆取消,釋放名額
confirmed → completed 不變 課程已完成,名額已消耗
pending → rejected 不變 pending 從未佔位,無需釋放
pending → expired 不變 pending 從未佔位,無需釋放
pending → member_cancelled 不變 pending 從未佔位,無需釋放

course_schedules.status 自動轉換規則(在 confirm/cancel 的同一 DB transaction 內執行):

// confirm 後 increment,檢查是否 full
if ($schedule->current_participants >= $schedule->max_participants) {
    $schedule->update(['status' => ScheduleStatus::Full]);
}

// cancel 後 decrement,檢查是否回 open
if ($schedule->current_participants < $schedule->max_participants
    && $schedule->status === ScheduleStatus::Full) {
    $schedule->update(['status' => ScheduleStatus::Open]);
}

cancelled 是終態,不受上述規則影響。

理由:與 specs/course-scheduling/spec.md 一致,current_participants 反映已確認(實際佔用)的人數。pending 是申請,不保留名額;Provider 確認時才真正佔位。


決策十四:ExpirePendingBookings 過期條件

選擇:過期條件為 status = 'pending'created_at <= now() - 48 hours。批次 update 即可,無需碰 current_participantspending 從未佔位)。

$count = Booking::where('status', BookingStatus::Pending)
    ->where('created_at', '<=', now()->subHours(48))
    ->update(['status' => BookingStatus::Expired]);

Log::info("ExpirePendingBookings: {$count} expired");

理由:pending 確認時才佔位(決策十三),因此過期只需改狀態,current_participants 與時段 status 均不受影響。批次 update 效率優於逐筆 transaction。


決策十二:前端路由新增

Member 新路由:
  /courses/:id          → 課程詳情(新增時段選擇區塊)
  /my-bookings          → 我的預約列表

Coach 新路由(在現有 /coach/* 下):
  /coach/schedules      → 時段管理
  /coach/bookings       → 預約管理

資料表設計

course_schedules

id                   bigint PK
diving_offer_id      bigint FK → diving_offers.id
provider_id          bigint FK → users.id
scheduled_date       date NOT NULL
start_time           time NOT NULL
max_participants     int NOT NULL (≥1)
current_participants int DEFAULT 0
status               string DEFAULT 'open'   ← PHP ScheduleStatus BackedEnum
created_at           timestamp
updated_at           timestamp

索引:
  idx_offer_status_date  (diving_offer_id, status, scheduled_date)  ← 公開 API 查詢
  idx_provider_id        (provider_id)                               ← Provider 管理頁

bookings

id          bigint PK
schedule_id bigint FK → course_schedules.id
member_id   bigint FK → users.id
participants int NOT NULL DEFAULT 1
total_price  int NOT NULL  (快照,單位:元)
status       string DEFAULT 'pending'   ← PHP BookingStatus BackedEnum
notes        text nullable
created_at   timestamp
updated_at   timestamp

索引:
  idx_member_status      (member_id, status)         ← Member 預約列表
  idx_schedule_status    (schedule_id, status)        ← 重複預約檢查、人數統計
  idx_status_created_at  (status, created_at)         ← ExpirePendingBookings Scheduler
  idx_status_sched       (status, schedule_id)        ← CompleteFinishedBookings Scheduler

API 路由總覽

公開
  GET  /api/diving-offers/{id}/schedules   → 取得課程可用時段

Member (auth:sanctum)
  GET  /api/member/bookings                → 我的預約列表
  POST /api/member/bookings                → 建立預約
  GET  /api/member/bookings/{id}           → 預約詳情
  DELETE /api/member/bookings/{id}         → 取消預約

Provider (auth:sanctum)
  GET  /api/provider/schedules             → 我的時段列表
  POST /api/provider/schedules             → 建立時段
  PUT  /api/provider/schedules/{id}        → 更新時段
  DELETE /api/provider/schedules/{id}      → 取消時段
  GET  /api/provider/bookings              → 課程預約列表
  PUT  /api/provider/bookings/{id}/confirm → 確認預約
  PUT  /api/provider/bookings/{id}/reject  → 拒絕預約
  PUT  /api/provider/bookings/{id}/cancel  → 取消預約

Risks / Trade-offs

  • Race condition on 最後一個名額 → 已用 lockForUpdate() 在 DB transaction 內處理

  • Scheduler 停擺(高風險) Scheduler 若未啟動,pending 預約永遠不過期、confirmed 課程永遠不完成,資料持續累積髒狀態。 Mitigation 三層:

    1. 日誌:每次 Job 執行結尾記錄 Log::info("ExpirePendingBookings: {$count} expired"),可在 Laravel log 中查驗
    2. 可觀測性:開發環境啟用 Laravel Telescope 監控 Schedule 執行;生產環境至少保留 storage/logs/laravel.log 並定期 rotate
    3. 手動補跑:兩支 Artisan Command 須可獨立執行(php artisan app:expire-pending-bookings),維運人員可在 Scheduler 異常時手動補跑,不依賴 cron
  • 取消後無退款 → 目前僅狀態標記,金流整合時需補充退款邏輯

  • completed 自動轉換 → 課程當天仍進行中的預約到凌晨才會轉 completed,業務上可接受

  • string status 未加 DB CHECK constraint → 合法值由應用層 BackedEnum 控制,若繞過 ORM 直接寫 DB 可能插入非法值;可接受此 trade-off,未來有需要可加 DB constraint

Closed Questions

  • Q1notes 欄位是否強制填寫?已決定:nullable。Member 預約時不強制填寫,notes 保留為選填欄位供日後使用。
  • Q2Provider 取消時段時,confirmed Booking 的通知方式?已決定:只改狀態。取消後 Booking 狀態變為 provider_cancelled,本次不觸發任何通知。Email/推播通知留給未來 Email 模組實作。