Files
CFDivePlatform/openspec/changes/realtime-booking-chat/tasks.md
T
a620906209 ab24f210a6 feat: 即時預約聊天室(realtime booking chat)
## 新增功能
- 會員與教練在 confirmed 預約期間可互傳文字與圖片訊息
- Presence Channel 顯示對方在線狀態(即時加入/離線)
- 已讀回執:對方讀取訊息後即時更新
- 預約完成(completed)後訊息封存唯讀
- 圖片上傳使用 intervention/image 處理(移除 EXIF、限制尺寸、強制重新編碼)

## 通知系統
- 收到新訊息時 Bell Icon 即時更新(NotificationCreated via private channel)
- 預約列表卡片顯示未讀角標(GET /api/bookings/messages/unread-counts 批次查詢)
- 瀏覽器分頁在背景時推送 Web Notification

## 基礎建設
- 引入 Laravel Reverb 作為自架 WebSocket 伺服器
- docker-compose 新增 reverb service(連接 proxy_net,供 NPM 代理)
- 前端安裝 laravel-echo + pusher-js,初始化 Echo plugin
- Dockerfile 補 GD JPEG/WebP/FreeType 支援

## 清理
- 移除 test_broadcast.php 與 resources/js/echo.js(Blade 殘留)
- 移除 /ping、/testpost 測試路由
- channels.php 改用 class-based 授權語法,移除 debug log
- BookingChat.vue otherType 提取為 computed 消除重複
- docker-entrypoint.sh 健康檢查改用 env var 取代硬編碼密碼

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 03:59:14 +08:00

9.2 KiB
Raw Blame History

1. 基礎建設與套件安裝

  • 1.1 [後端] 安裝 laravel/reverbcomposer require laravel/reverb,執行 php artisan reverb:install 生成 config/reverb.php
  • 1.2 [後端] 安裝 intervention/imagecomposer require intervention/image;用途:上傳圖片時移除 EXIF(含 GPS 座標)、強制重新編碼為 jpg/png 以防格式偽裝、限制最大尺寸(長邊 2048px)
  • 1.3 [前端] 安裝 laravel-echopusher-jsnpm install laravel-echo pusher-js
  • 1.4 [後端] 更新 .env:設定 BROADCAST_CONNECTION=reverbREVERB_APP_ID=cfdiveREVERB_APP_KEY32 字元隨機)、REVERB_APP_SECRET32 字元隨機)、REVERB_HOST=reverbDocker service DNS,非 bind address)、REVERB_PORT=8080;另補 Vite 前端用的 VITE_REVERB_APP_KEYVITE_REVERB_HOST=ws.hank-space.comVITE_REVERB_PORT=443VITE_REVERB_SCHEME=https
  • 1.5 [後端] 在 config/broadcasting.php 確認 reverb driver 設定正確(hostport 從 env 讀取)
  • 1.6 [後端] 在 bootstrap/app.php 啟用 broadcasting:確認 ->withBroadcasting() 已加入,或在 routes/api.php 明確呼叫 Broadcast::routes(['middleware' => ['auth:sanctum']]) 以確保 /broadcasting/auth endpoint 存在並受 Sanctum 保護

2. Docker 與 Infrastructure 設定

  • 2.1 [後端] 在 docker-compose.yml 新增 reverb service:複用 cfdive-platform imagecommand: php artisan reverb:start --host=0.0.0.0 --port=8080 --debug,連接 cfdive-networkproxy_netrestart: unless-stoppeddepends_on: app
  • 2.2 [Infrastructure] 在 DNS 新增 A Recordws.hank-space.com → VPS IP
  • 2.3 [Infrastructure] 在 Nginx Proxy Manager 新增 Proxy HostDomain ws.hank-space.comForward 至 reverb:8080,啟用 WebSocket support,申請 SSL 憑證
  • 2.4 [後端] 執行 docker-compose up --build reverb 驗證 Reverb 容器啟動正常

3. 資料庫

  • 3.1 [後端] 建立 migration create_booking_messages_table:欄位 idbooking_idFK)、sender_idFK users)、sender_typeenum: member/provider)、typeenum: text/image)、contenttext)、read_attimestamp nullable)、timestamps;加 index (booking_id, created_at)
  • 3.2 [後端] 執行 php artisan migrate 並確認資料表建立

4. 後端 Model、Channel 與 Event

  • 4.1 [後端] 建立 app/Models/BookingMessage.php:定義 $fillablebooking() belongsTo、sender() morphTo 或一般 belongsTo(依 sender_type 切換)
  • 4.2 [後端] 建立 app/Broadcasting/BookingPresenceChannel.php:實作 join() 方法,eager load scheduleMember 驗證 booking->member_id === $user->idProvider 驗證 booking->schedule->provider_id === $user->idconfirmed 以外狀態返回 false;授權成功回傳 ['user_id', 'user_type', 'name']
  • 4.3 [後端] 在 routes/channels.php 註冊 Broadcast::channel('presence-booking.{bookingId}', BookingPresenceChannel::class)
  • 4.4 [後端] 建立 app/Events/MessageSent.phpimplements ShouldBroadcastNow(同步廣播,MVP 不走 queue),broadcastOn() 返回 new PresenceChannel("presence-booking.{$this->message->booking_id}")broadcastWith() 回傳 idsender_idsender_typetypecontentcreated_at
  • 4.5 [後端] 建立 app/Events/MessageRead.phpimplements ShouldBroadcastNow,廣播 reader_typelast_read_message_id

5. 後端 API

  • 5.1 [後端] 建立 app/Http/Controllers/BookingMessageController.php,方法:index(取得歷史)、store(發送文字/圖片)、markRead(標記已讀)
  • 5.2 [後端] index 方法:驗證使用者為參與方,回傳 booking.messages()->orderBy('created_at')->get()confirmedcompleted 均可讀取
  • 5.3 [後端] store 方法:驗證 booking status 為 confirmed(其他狀態回傳 403);text 訊息驗證 content 非空;image 訊息驗證 filemimes: jpg,png,gif,webpmax: 10240KB),存至 Storage::disk('public'),路徑 booking-images/{uuid}.{ext}content 存完整 URLStorage::url(...));建立 BookingMessagedispatch MessageSent eventShouldBroadcastNow
  • 5.4 [後端] markRead 方法:更新「對方發送」且「id ≤ last_read_message_id」且「read_at IS NULL」的訊息之 read_atbooking status 為 confirmed 時 dispatch MessageRead eventcompleted 時只更新 DB,不 broadcast(頻道已關閉)
  • 5.5 [後端] 在 routes/api.php 新增路由(member 與 provider 各自的 auth middleware group):GET /api/bookings/{booking}/messagesPOST /api/bookings/{booking}/messagesPOST /api/bookings/{booking}/messages/read

6. 前端 Echo 初始化

  • 6.1 [前端] 在 frontend/src/plugins/echo.js(新建)初始化 Laravel Echobroadcaster: 'reverb'wsHost: ws.hank-space.comwsPort: 443wssPort: 443forceTLS: trueenabledTransports: ['ws', 'wss']
  • 6.2 [前端] 在 frontend/src/plugins/echo.js 支援雙 tokenMember 用 localStorage.getItem('token')Coach 用 localStorage.getItem('coach_token'),依當前角色動態設定 Authorization headerauthEndpoint 使用完整 URL${import.meta.env.VITE_API_URL}/broadcasting/auth(跨 domain 不可使用相對路徑)
  • 6.3 [前端] 在 main.js 掛載 Echo plugin

7. 前端訊息元件

  • 7.1 [前端] 建立 frontend/src/components/BookingChat.vueprops 接收 bookingIdbookingStatuscurrentUserType
  • 7.2 [前端] BookingChat.vue 訂閱 presence-booking.{bookingId},處理 .here()(顯示在線狀態)、.joining().leaving()
  • 7.3 [前端] BookingChat.vue 監聽 MessageSent event,新訊息即時 append 到訊息列表
  • 7.4 [前端] BookingChat.vue 監聽 MessageRead event,更新訊息「已讀」標記
  • 7.5 [前端] BookingChat.vue 實作文字輸入框與送出按鈕,call POST /api/bookings/{id}/messages
  • 7.6 [前端] BookingChat.vue 實作圖片上傳按鈕(<input type="file" accept="image/*">),以 FormData 送出
  • 7.7 [前端] BookingChat.vueconfirmed 狀態顯示輸入區;completed 狀態顯示「對話已封存」提示;其他狀態不渲染元件
  • 7.8 [前端] 元件 mount 時呼叫 GET /api/bookings/{id}/messages 載入歷史訊息,並送出 markRead

8. 前端嵌入預約詳情頁

  • 8.1 [前端] 在 Member 的預約詳情頁(src/views/MyBookingsView.vue 展開區塊)嵌入 <BookingChat> 元件
  • 8.2 [前端] 在 Coach 的預約管理頁(src/views/coach/BookingManagerView.vue)嵌入 <BookingChat> 元件,點擊「訊息」按鈕展開
  • 8.3 [前端] 在預約列表卡片顯示未讀訊息角標:實作 GET /api/bookings/messages/unread-counts(一次回傳所有 booking 的未讀數,非逐筆呼叫);建立 useBookingUnreadCounts composable60s 輪詢);BookingChatemit('read') 讓父層即時清零角標

8.5 訊息通知系統(實作中追加,超出原始 scope)

  • 8.5.1 [後端] 建立 NewBookingMessageNotificationdatabase channel only(不寄信);title 含寄件方姓名(provider 加「教練」前綴);不走 Queue(同步寫入,確保廣播前 DB 已有資料)
  • 8.5.2 [後端] 建立 NotificationCreated eventShouldBroadcastNow,廣播至 private-App.Models.User.{id}(複用 channels.php 已有的授權);broadcastAs() = 'notification.created'
  • 8.5.3 [後端] BookingMessageController::store() 在 broadcast MessageSent 後,同步 notify receiver,再 broadcast NotificationCreated
  • 8.5.4 [前端] notifications.js store 新增 startRealtime(userId) / stopRealtime():訂閱 private-App.Models.User.{id},收到 notification.created 立刻呼叫 fetchUnreadCount()
  • 8.5.5 [前端] auth.jscoachAuth.jsinit() / setAuth() / logout() 均呼叫 startRealtime / stopRealtime
  • 8.5.6 [前端] BookingChat.vue 新增瀏覽器通知(Web Notifications API):mount 時請求權限;收到對方 MessageSentdocument.hidden 時推送;tag: booking-chat-{id} 防止同一預約疊加通知

9. 整合測試與手動驗證

  • 9.1 [整合測試] 驗證 /broadcasting/auth 端點可存取(不是 404):分別用 member token 與 coach token 送出請求,確認路由已正確註冊且 Sanctum middleware 生效;合法參與方回傳 200,非參與方回傳 403,非 confirmed 狀態回傳 403
  • 9.2 [整合測試] 驗證 POST /api/bookings/{id}/messages:text 訊息成功建立且廣播;image 上傳成功且 URL 可存取;invalid 檔案回傳 422
  • 9.3 [整合測試] 驗證 markRead:己方訊息不更新;對方訊息 read_at 被設定;MessageRead 廣播觸發
  • 9.4 [整合測試] 驗證封存:completed 預約 POST 訊息回傳 403,GET 歷史正常回傳
  • 9.5 [手動驗證] 開兩個瀏覽器分別登入 Member 與 Coach,確認訊息即時雙向傳達、在線狀態顯示正確、已讀回執正常觸發
  • 9.6 [手動驗證] 測試圖片訊息:上傳圖片後對方即時看到圖片
  • 9.7 [手動驗證] 關閉一個視窗,確認另一端顯示「對方已離線」