Files
CFDivePlatform/openspec/changes/archive/2026-05-12-booking-system/design.md
T
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

386 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
CFDivePlatform 是 Laravel 11 + Vue 3 的潛水課程媒合平台。目前 Member 只能瀏覽課程,無法完成預約。需要在現有 Sanctum 認證體系上新增預約流程,並引入 Laravel Scheduler 處理自動狀態轉換。
現有關鍵資料模型:`users`(三角色)、`diving_offers`(含 `price``provider_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` 管理合法值。
```php
// 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 前統一驗證合法性:
```php
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_time`Carbon datetime)。
```php
$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 內執行重複性檢查。
```php
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` 回傳條件為:
```sql
WHERE diving_offer_id = :id
AND status = 'open'
AND scheduled_date >= CURDATE()
ORDER BY scheduled_date ASC, start_time ASC
```
`full``cancelled` 時段不回傳;過去日期時段不回傳。
**理由**:只回傳 `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_status``to_status``changed_by`user_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 內執行):
```php
// 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_participants`pending 從未佔位)。
```php
$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
- **Q1`notes` 欄位是否強制填寫?** → **已決定:nullable**。Member 預約時不強制填寫,`notes` 保留為選填欄位供日後使用。
- **Q2Provider 取消時段時,confirmed Booking 的通知方式?** → **已決定:只改狀態**。取消後 Booking 狀態變為 `provider_cancelled`,本次不觸發任何通知。Email/推播通知留給未來 Email 模組實作。