後端 - 新增 6 個 Notification class(預約建立/確認/拒絕/取消/完成、收到評價),database + mail 雙 channel - 新增 NotificationController(list / unread-count / markRead / markAllRead / destroy) - 整合通知觸發至 MemberBookingController、ProviderBookingController、CompleteFinishedBookings、ReviewController - 新增 notifications / jobs / failed_jobs migration - Docker Compose 加入 queue-worker、mailpit service - DivingOffer 補上 provider() 關聯 前端 - 新增 notificationStore(Polling 30s/60s 自適應 + Page Visibility API) - 新增 NotificationBell(未讀 Badge)、NotificationDrawer(側邊通知中心) - main.js:auth store init 前置於 router.use(),修正 beforeEach guard 時序問題 - notificationAxios:依路徑動態選擇 member/coach token - NotificationDrawer:改用 new URL().pathname 提取 action_url 路徑 OpenSpec - 歸檔 notification-system change - 同步 notification-core / notification-email / notification-triggers specs 至主規格 - 更新 booking-lifecycle / review-lifecycle spec(補充通知觸發 requirement) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
13 KiB
Context
平台目前已有完整的預約七狀態機與評價系統,但所有狀態轉換都是「靜默」執行,使用者只能回到頁面主動查看。本設計在不引入複雜即時通訊基礎設施的前提下,以 Laravel 內建 Notification + 前端 Polling 實作通知系統。
Goals / Non-Goals
Goals:
- 站內通知(In-App):Bell Icon 未讀計數 + 通知中心 Drawer,覆蓋 Member / Provider 兩個角色
- Email 通知:以 Laravel Queue + Mailable 非同步寄出,本地用 Mailpit 測試
- 觸發整合:BookingService、ReviewService、Admin 審核流程各轉換點
- 標記已讀(單一 / 全部)、刪除通知
Non-Goals:
- WebSocket / Push Notification(瀏覽器推播)
- SMS 通知
- 通知偏好設定(使用者選擇開關)
- Admin 角色通知(本次範圍僅 Member + Provider)
Decisions
1. 使用 Laravel 內建 Notification 系統
選擇:Notifiable trait + 各 Notification class 各自控制 via()
理由:
databasechannel 自動建立notifications資料表,schema 標準化mailchannel 直接整合 Mailable + Queue- 每個 class 獨立控制
via(),避免「評價通知意外寄出 Email」等誤觸;新增類型只需新增一個 class
各 Notification class 的 via() 設定:
| Class | via() |
說明 |
|---|---|---|
BookingCreatedNotification |
['database', 'mail'] |
有新預約,Provider 需即時知道 |
BookingConfirmedNotification |
['database', 'mail'] |
確認是 Member 最期待的通知 |
BookingRejectedNotification |
['database', 'mail'] |
需要 Email 確保 Member 收到 |
BookingCancelledNotification |
['database', 'mail'] |
取消對雙方均重要 |
BookingCompletedNotification |
['database', 'mail'] |
Email CTA 引導評價 |
ReviewReceivedNotification |
['database'] |
告知性通知,不值得寄 Email 打擾 |
放棄的方案:所有 class 共用一個 via() 設定 — 會導致評價通知也寄 Email,過度打擾 Provider。
2. 前端即時性:Polling(非 WebSocket)
選擇:前端登入後 Polling GET /api/notifications/unread-count,搭配 Page Visibility API 節省請求
理由:
- 平台目前流量低,WebSocket 基礎設施(Pusher / Laravel Echo Server)成本不對等
- SSE 需要長連線,Docker 環境 Nginx timeout 需另外調整
- 30 秒延遲對「預約確認」類通知可接受
降頻邏輯(細化):
登入後 → 立即執行第一次 fetch(不等待 30s)
有未讀(count > 0) → interval = 30s
無未讀(count = 0) → interval = 60s
頁面隱藏(visibilitychange = hidden) → 暫停 interval
頁面重新顯示(visibilitychange = visible) → 立即 fetch 一次,然後重啟 interval
登出 → clearInterval + removeEventListener
實作方式:startPolling() 建立 setInterval,每次 fetch 後比較新舊 count:若 count 從 > 0 變為 0(或反之),clearInterval 並以新 interval 重啟。Page Visibility 由 document.addEventListener('visibilitychange', handler) 控制。
升級路徑:未來可替換為 Laravel Reverb(官方 WebSocket server),前端改用 Echo,store 的 unreadCount/notifications state 介面不變。
3. Queue Driver:database(現有 MySQL)
選擇:QUEUE_CONNECTION=database,使用現有 MySQL
理由:
- 專案已有 MySQL,不需額外部署 Redis
- Email 通知量少(非高頻),database queue 足夠
- 啟動命令加入
php artisan queue:work --daemon或在 Docker CMD 中加入
升級路徑:QUEUE_CONNECTION=redis,只需改 .env,不動業務邏輯。
4. 通知類型設計(data JSON 欄位統一格式)
每個 Notification class 的 toArray() 回傳統一結構:
{
"type": "booking_confirmed",
"title": "預約已確認",
"body": "你的《自由潛水入門》課程預約已由教練確認",
"action_url": "http://localhost:5173/my-bookings",
"related_id": 123,
"related_type": "booking"
}
action_url 格式決定(修正):action_url 儲存完整 URL(含 FRONTEND_URL prefix),前端以 new URL(action_url).pathname 提取路徑再傳入 router.push()。不含個別 booking ID,原因:前端路由只有 /my-bookings(列表),無 /my-bookings/:id 詳情頁,帶 ID 會導致 404。
前端根據 type 決定 icon 顏色與動作連結。
5. 通知觸發架構:直接插入現有 Controller(不建立 Service 層)
現況確認:專案無 BookingService / ReviewService。業務邏輯分散於:
MemberBookingController(建立預約、Member 取消)ProviderBookingController(確認、拒絕、Provider 取消、手動完成)CompleteFinishedBookingsCommand(排程自動完成)ReviewController::store()(評價建立)
選擇:直接在上述 Controller / Command 的對應方法中,於主業務 DB 操作後插入 $user->notify(...),以 try/catch 包裹。
理由:
- 本次任務不需要 Service 抽象,建立 Service 只是為了通知而引入不必要的重構
- Inline notify 可讀性佳,出問題容易定位到發送點
- Observer 或 Event/Listener 會讓觸發點不直觀(多一層間接)
DivingOffer provider() 關聯需新增:
DivingOffer 有 provider_id FK 但無 Eloquent 關聯方法。實作前需在 DivingOffer model 補上:
public function provider(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(User::class, 'provider_id');
}
之後 ReviewController 及各 BookingController 統一使用 $offer->provider(而非 $offer->user)。
ReviewController 取得 Provider 的正確方式:
// ReviewController::store() 中
$offer = DivingOffer::with('provider')->findOrFail($offerId);
$provider = $offer->provider;
try {
$provider->notify(new ReviewReceivedNotification($review));
} catch (\Throwable $e) {
\Log::error('ReviewNotification failed: ' . $e->getMessage());
}
BookingCancelledNotification 依 $cancelledBy 區分文案:
$cancelledBy |
通知對象 | title | body |
|---|---|---|---|
'member' |
Provider | 學員取消了預約 | 學員已取消《課程名稱》的預約(時段:日期) |
'provider' |
Member | 教練取消了你的預約 | 教練已取消你的《課程名稱》預約(時段:日期),如有疑問請聯繫教練 |
// 使用範例
// MemberBookingController::cancel() 中
$provider = $booking->schedule->divingOffer->provider;
try {
$provider->notify(new BookingCancelledNotification($booking, cancelledBy: 'member'));
} catch (\Throwable $e) { \Log::error(...); }
// ProviderBookingController::cancel() 中
$member = $booking->member;
try {
$member->notify(new BookingCancelledNotification($booking, cancelledBy: 'provider'));
} catch (\Throwable $e) { \Log::error(...); }
6. Email 模板:Laravel Markdown Mailable
使用 php artisan make:mail + markdown 參數,產生 resources/views/emails/notifications/ 下的 Blade 模板。本地使用 Mailpit(Docker service mailpit,port 1025/8025)攔截信件,不真實發送。
7-前置. Email action_url — FRONTEND_URL 設定
現況:.env 已有 FRONTEND_URL=http://localhost:5173,但 config/app.php 未註冊此值,無法透過 config() 讀取。
決定:在 config/app.php 加入:
'frontend_url' => env('FRONTEND_URL', 'http://localhost:5173'),
Notification class 中使用:
'action_url' => config('app.frontend_url') . '/my-bookings/' . $this->booking->id,
.env.example 同步補上 FRONTEND_URL=http://localhost:5173。
各場景 action_url 對應:
| 通知 | action_url |
|---|---|
| BookingCreated(→ Provider) | {FRONTEND_URL}/coach/bookings |
| BookingConfirmed / Rejected / Cancelled / Completed(→ Member) | {FRONTEND_URL}/my-bookings(無 booking ID,前端路由無 /my-bookings/:id) |
| ReviewReceived(→ Provider) | {FRONTEND_URL}/coach/reviews |
7. API 路由完整定義
所有路由掛在 auth:sanctum middleware 下,Member token 與 Provider token 均適用(Notifiable 基於 User model,兩者共用同一張 notifications 資料表)。
| Method | Path | Controller@method | 說明 |
|---|---|---|---|
GET |
/api/notifications |
NotificationController@index |
列表(分頁 20,DESC),含 unread_count |
GET |
/api/notifications/unread-count |
NotificationController@unreadCount |
Polling 專用,回傳 { count } |
PATCH |
/api/notifications/{id}/read |
NotificationController@markRead |
單一標記已讀 |
PATCH |
/api/notifications/read-all |
NotificationController@markAllRead |
全部標記已讀 |
DELETE |
/api/notifications/{id} |
NotificationController@destroy |
刪除單筆 |
路由順序注意:/read-all 必須定義在 /{id}/read 之前,避免 Laravel 把 read-all 當成 {id} 綁定。
8. 觸發場景完整列表
| # | 事件 | 觸發位置 | 通知對象 | Channels |
|---|---|---|---|---|
| 1 | 預約建立(pending) |
BookingService::create() |
Provider | DB + Mail |
| 2 | 預約確認(confirmed) |
BookingService::confirm() |
Member | DB + Mail |
| 3 | 預約拒絕(rejected) |
BookingService::reject() |
Member | DB + Mail |
| 4 | 預約取消(member_cancelled) |
BookingService::cancelByMember() |
Provider | DB + Mail |
| 5 | 預約取消(provider_cancelled) |
BookingService::cancelByProvider() |
Member | DB + Mail |
| 6 | 預約完成(completed) |
BookingService::complete() |
Member | DB + Mail |
| 7 | 收到評價 | ReviewService::create() |
Provider | DB only |
場景共 7 個(含取消分 Member/Provider 兩方),對應 6 個 Notification class(
BookingCancelledNotification透過$cancelledBy參數區分文案)。
Risks / Trade-offs
| 風險 | 緩解策略 |
|---|---|
CompleteFinishedBookings N+1 查詢 |
現行用 bulk ->update() 無法逐筆 notify,需改為 ->with(['member', 'schedule.divingOffer.provider'])->get() + loop;notify 仍在 loop 內,但 eager load 確保無 N+1 |
| Polling 造成 API 請求量上升 | 只在使用者登入且頁面 visible 時輪詢;未讀數 0 時降頻至 60s |
| Queue Worker 未啟動導致 Email 卡住 | Docker Compose 加入 queue-worker service,supervisor 管理 |
notifications 資料表無限增長 |
建議每月清理 90 天前已讀通知(php artisan notifications:prune,Laravel 內建) |
| Email 寄信失敗無重試上限 | Queue job 設定 $tries = 3,失敗寫入 failed_jobs |
Migration Plan
- 執行
php artisan notifications:table+php artisan queue:table→ migrate - 建立 Notification classes(6 種觸發場景)
- 整合 BookingService / ReviewService / Admin controller
- 建立 NotificationController + API routes
- Docker Compose 加入 queue-worker service
- 前端:Notification Pinia store → Bell Icon 元件 → Drawer 元件 → 整合至兩個 NavBar
9. 前端 Store 初始化時序
問題:Vue Router 的 beforeEach guard 在 App.vue 的 onMounted 之前執行。原本設計把三個 auth store 的 init()(讀 localStorage → 設定 token.value)放在 onMounted,導致 guard 跑時 isLoggedIn 永遠是 false,所有 protected route 均被踢回 login。
決定:在 main.js 中,app.use(pinia) 安裝後、app.use(router) 安裝前,同步呼叫三個 store 的 init():
app.use(pinia)
useAuthStore().init()
useCoachAuthStore().init()
useAdminAuthStore().init()
app.use(router)
app.mount('#app')
影響:App.vue 不再需要 onMounted,三個 auth store import 從 App.vue 移至 main.js。
10. 通知 API Token 選擇邏輯
問題:Member 與 Coach 使用同一個 notificationAxios 實例,interceptor 原本固定以 token || coach_token 順序取用。若瀏覽器同時持有兩種 token(測試情境),永遠使用 member token,導致 coach 通知 API 回傳 member 的空資料。
決定:依當前頁面路徑動態選 token:
const isCoachPage = window.location.pathname.startsWith('/coach')
const token = isCoachPage
? (localStorage.getItem('coach_token') || localStorage.getItem('token'))
: (localStorage.getItem('token') || localStorage.getItem('coach_token'))
理由:路徑是判斷「使用者當前身份上下文」最直接的信號,無需引入 Pinia store 至 axios interceptor(避免循環依賴)。
Open Questions
所有問題已關閉,實作可直接開始。
| 問題 | 決定 |
|---|---|
| Mailpit 是否已加入 Docker Compose? | 否,需在 task 1.6 補上。docker-compose.yml 新增 mailpit service(axllent/mailpit),.env 設定 MAIL_HOST=mailpit MAIL_PORT=1025。 |
| Admin 角色通知未來是否需要? | 本次排除。Admin 主要操作在後台(有即時 UI feedback),不在此 change 範圍,未來若需要另開 change。 |
| 通知是否需要「點擊後自動標記已讀」行為? | 是。點擊 Drawer 中任一通知項目時,前端呼叫 PATCH /api/notifications/{id}/read,然後才執行 router.push(action_url)(不需等待 API response,Optimistic update)。 |