Files
CFDivePlatform/openspec/changes/archive/2026-05-17-notification-system/design.md
T
a620906209 03f8caf3e9 feat:實作通知系統 — 站內通知、Email 通知、Polling 機制
後端
- 新增 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>
2026-05-17 22:26:14 +08:00

13 KiB
Raw Blame History

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()

理由

  • database channel 自動建立 notifications 資料表,schema 標準化
  • mail channel 直接整合 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),前端改用 Echostore 的 unreadCount/notifications state 介面不變。


3. Queue Driverdatabase(現有 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 取消、手動完成)
  • CompleteFinishedBookings Command(排程自動完成)
  • ReviewController::store()(評價建立)

選擇:直接在上述 Controller / Command 的對應方法中,於主業務 DB 操作後插入 $user->notify(...),以 try/catch 包裹。

理由

  • 本次任務不需要 Service 抽象,建立 Service 只是為了通知而引入不必要的重構
  • Inline notify 可讀性佳,出問題容易定位到發送點
  • Observer 或 Event/Listener 會讓觸發點不直觀(多一層間接)

DivingOffer provider() 關聯需新增

DivingOfferprovider_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 模板。本地使用 MailpitDocker service mailpitport 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 列表(分頁 20DESC),含 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 classBookingCancelledNotification 透過 $cancelledBy 參數區分文案)。

Risks / Trade-offs

風險 緩解策略
CompleteFinishedBookings N+1 查詢 現行用 bulk ->update() 無法逐筆 notify需改為 ->with(['member', 'schedule.divingOffer.provider'])->get() + loopnotify 仍在 loop 內,但 eager load 確保無 N+1
Polling 造成 API 請求量上升 只在使用者登入且頁面 visible 時輪詢;未讀數 0 時降頻至 60s
Queue Worker 未啟動導致 Email 卡住 Docker Compose 加入 queue-worker servicesupervisor 管理
notifications 資料表無限增長 建議每月清理 90 天前已讀通知(php artisan notifications:pruneLaravel 內建)
Email 寄信失敗無重試上限 Queue job 設定 $tries = 3,失敗寫入 failed_jobs

Migration Plan

  1. 執行 php artisan notifications:table + php artisan queue:table → migrate
  2. 建立 Notification classes6 種觸發場景)
  3. 整合 BookingService / ReviewService / Admin controller
  4. 建立 NotificationController + API routes
  5. Docker Compose 加入 queue-worker service
  6. 前端:Notification Pinia store → Bell Icon 元件 → Drawer 元件 → 整合至兩個 NavBar

9. 前端 Store 初始化時序

問題Vue Router 的 beforeEach guard 在 App.vueonMounted 之前執行。原本設計把三個 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 serviceaxllent/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 responseOptimistic update)。