Files
CFDivePlatform/openspec/changes/realtime-booking-chat/specs/booking-chat/spec.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

8.0 KiB
Raw Blame History

ADDED Requirements

Requirement: 發送文字訊息

Member 與 Provider SHALL 能在 confirmed 狀態的預約中透過 POST /api/bookings/{id}/messages 發送文字訊息。訊息儲存後系統 SHALL 廣播 MessageSent event 至 presence-booking.{id} 頻道。

Scenario: 成功發送文字訊息

  • WHEN 已認證的 Member 或 Provider 對自己參與的 confirmed 預約送出 POST /api/bookings/{id}/messagesbody 為 { type: 'text', content: '...' }
  • THEN 系統建立 BookingMessage 記錄,回傳 201,並透過 WebSocket 廣播 MessageSent event(含 idsender_idsender_typetypecontentcreated_at

Scenario: 預約非 confirmed 狀態時不可發送

  • WHEN 預約 status 不為 confirmed
  • THEN 系統回傳 403,告知訊息功能僅在預約確認期間開放

Scenario: 非參與方不可發送

  • WHEN 非該預約的 Member 或對應 Provider 嘗試發送訊息
  • THEN 系統回傳 403 Forbidden

Scenario: 訊息內容不可為空

  • WHEN content 為空字串或未提供
  • THEN 系統回傳 422errors.content 說明必填

Requirement: 發送圖片訊息

Member 與 Provider SHALL 能在 confirmed 預約中上傳圖片作為訊息。圖片透過 HTTP multipart POST 上傳至 Laravel Storage,系統建立 type: imageBookingMessage 並廣播圖片 URL。

Scenario: 成功上傳並發送圖片

  • WHEN 已認證使用者對 confirmed 預約送出 POST /api/bookings/{id}/messagesContent-Type: multipart/form-datatype: imagefile 為有效圖片(jpg/png/gif/webp,≤ 10MB
  • THEN 系統儲存圖片至 Storage,建立 BookingMessagecontent 存圖片 URL),廣播 MessageSent event,回傳 201

Scenario: 不支援的檔案類型被拒絕

  • WHEN 上傳的 file 非 jpg/png/gif/webp
  • THEN 系統回傳 422errors.file 說明僅支援圖片格式

Scenario: 超過大小限制被拒絕

  • WHEN 圖片檔案大小超過 10MB
  • THEN 系統回傳 422errors.file 說明大小限制

Requirement: 讀取訊息歷史

Member 與 Provider SHALL 能透過 GET /api/bookings/{id}/messages 取得該預約的完整訊息歷史,按 created_at 升序排列。

Scenario: 成功取得訊息歷史

  • WHEN 已認證的參與方送出 GET /api/bookings/{id}/messages
  • THEN 系統回傳訊息陣列,每筆包含 idsender_idsender_typetypecontentread_atcreated_at

Scenario: 非參與方無法讀取歷史

  • WHEN 非該預約參與方嘗試讀取訊息
  • THEN 系統回傳 403 Forbidden

Scenario: 預約完成後仍可讀取歷史

  • WHEN 預約 status 為 completed,參與方送出 GET /api/bookings/{id}/messages
  • THEN 系統回傳完整歷史訊息(唯讀,不可繼續發送)

Requirement: 訊息封存

預約狀態轉換為 completed 時,系統 SHALL 自動關閉對應 Presence Channel,訊息歷史轉為唯讀。終態(rejected、expired、member_cancelled、provider_cancelled)的預約 SHALL 不具備訊息功能。

Scenario: completed 後不可發送新訊息

  • WHEN 預約 status 為 completed,任一方嘗試 POST /api/bookings/{id}/messages
  • THEN 系統回傳 403,告知預約已結束,訊息已封存

Scenario: 終態預約無訊息記錄也無法發送

  • WHEN 預約 status 為 rejectedexpiredmember_cancelledprovider_cancelled
  • THEN GET /api/bookings/{id}/messages 回傳空陣列;POST 回傳 403

Requirement: 標記訊息已讀

接收方讀取訊息時,系統 SHALL 更新 read_at,並廣播 MessageRead event 至頻道。

Scenario: 成功標記已讀

  • WHEN 已認證使用者送出 POST /api/bookings/{id}/messages/read(含 last_read_message_id
  • THEN 系統將該訊息及之前所有訊息(自己未讀的)的 read_at 更新為當前時間,廣播 MessageRead event(含 reader_typelast_read_message_id

Scenario: 不可標記自己的訊息

  • WHEN 發送方嘗試標記自己發出的訊息為已讀
  • THEN 系統忽略(不更新、不廣播),回傳 200

Requirement: 未讀訊息計數 Endpoint

系統 SHALL 提供 GET /api/bookings/messages/unread-counts endpoint,一次回傳當前使用者所有相關預約的未讀訊息數,避免前端逐筆呼叫造成 N+1 請求。

Scenario: 取得未讀計數

  • WHEN 已認證的 Member 或 Provider 送出 GET /api/bookings/messages/unread-counts
  • THEN 系統回傳 { status: true, data: { "{bookingId}": count, ... } },僅包含未讀數 > 0 的 booking;無未讀則回傳空物件 {}
  • AND 計算邏輯為:對方發送(sender_type 為對方角色)且 read_at IS NULL 的訊息數量

Scenario: 路由優先順序正確

  • WHEN 路由檔案定義 /bookings/messages/unread-counts
  • THEN 該路由必須在 /bookings/{booking}/messages 之前註冊,以防 Route Model Binding 將 messages 誤判為 {booking} 參數

Requirement: 預約列表未讀角標

前端 SHALL 在預約列表(Member 的 MyBookingsView、Coach 的 BookingManagerView)的每筆預約卡片上,以紅色角標顯示未讀訊息數。

Scenario: 有未讀訊息時顯示角標

  • WHEN GET /api/bookings/messages/unread-counts 回傳某 booking 的 count > 0
  • THEN 對應預約卡片上顯示紅色角標(數字)

Scenario: 開啟聊天視窗後角標即時清零

  • WHEN 使用者展開某預約的 BookingChat 元件,元件 mount 時呼叫 markRead
  • THEN BookingChat emit read 事件,父層呼叫 clearCount(bookingId) 即時清零角標,無需等下一輪 60s 輪詢

Requirement: 站內通知(Bell Icon)即時更新

當使用者收到新的聊天訊息時,系統 SHALL 即時更新其 Bell Icon 的未讀通知計數,延遲不超過廣播傳輸時間(毫秒級),不依賴輪詢。

Scenario: 發送訊息觸發接收方 Bell 更新

  • WHEN 使用者 A 透過 POST /api/bookings/{id}/messages 成功發送訊息
  • THEN 系統同步(非 queue)寫入 notifications 資料表(NewBookingMessageNotificationdatabase channel only
  • AND 系統 broadcast NotificationCreated event 至接收方的 private-App.Models.User.{receiverId} 頻道
  • AND 接收方前端收到 .notification.created 事件後立即重新 fetch /notifications/unread-count

Scenario: 通知 title 包含寄件方姓名

  • WHEN 通知寫入 DB
  • THEN title 欄位格式為 "{senderLabel} 傳來新訊息",其中 Provider 的 senderLabel 加上「教練 」前綴(例:「教練 王小明 傳來新訊息」),Member 直接使用姓名

Scenario: 不寄送 Email 通知

  • WHEN 新訊息通知寫入
  • THEN via() 僅回傳 ['database'],不透過 mail channel 發送 Email

Requirement: 瀏覽器 Web Notification(背景通知)

當使用者的瀏覽器分頁處於背景(document.hidden === true)時,系統 SHALL 觸發瀏覽器原生通知,告知有新的聊天訊息。

Scenario: 分頁在背景時收到訊息顯示通知

  • WHEN BookingChat 收到對方的 MessageSent event
  • AND document.hidden === true
  • AND Notification.permission === 'granted'
  • THEN 系統建立一則 Notificationtag: 'booking-chat-{bookingId}'(防止同預約疊加多則)

Scenario: 分頁在前景時不顯示通知

  • WHEN BookingChat 收到對方的 MessageSent event
  • AND document.hidden === false(使用者正在看畫面)
  • THEN 系統不推送 Web Notification(避免干擾)

Scenario: 元件 mount 時請求通知權限

  • WHEN BookingChat 元件 mount
  • THEN 呼叫 Notification.requestPermission(),取得使用者授權後方可推送通知

Scenario: 自己的訊息不觸發通知

  • WHEN MessageSent event 的 sender_type 與當前使用者角色相同
  • THEN 不推送 Web Notification(只對「對方」的訊息觸發)