Compare commits
3 Commits
6fcf57a2d2
...
eff2dd273c
| Author | SHA1 | Date | |
|---|---|---|---|
| eff2dd273c | |||
| 4a5559a79e | |||
| 83e0e599a0 |
+3
-1
@@ -5,6 +5,8 @@
|
||||
## What Changes
|
||||
|
||||
- 新增 `booking_messages` 資料表,儲存文字與圖片訊息,與 `bookings` 一對多關聯
|
||||
- 新增站內通知系統:發送訊息時同步寫入 `notifications` 資料表(`NewBookingMessageNotification`,database channel only),並廣播 `NotificationCreated` event 至 `private-App.Models.User.{receiverId}` 頻道,前端即時更新 Bell Icon 未讀計數(**實作中追加,超出原始 scope**)
|
||||
- 新增瀏覽器 Web Notification:分頁在背景時收到對方訊息,透過 Notifications API 推送原生通知(`tag: booking-chat-{id}` 防疊加)(**實作中追加**)
|
||||
- 引入 **Laravel Reverb** 作為自架 WebSocket 伺服器(docker-compose 新增 `reverb` service)
|
||||
- 每個 confirmed 預約建立一條 **Presence Channel**(`presence-booking.{id}`),同時承載訊息推播與在線狀態
|
||||
- 已讀回執:對方在頻道內且讀取訊息時觸發 `MessageRead` event
|
||||
@@ -28,7 +30,7 @@
|
||||
|
||||
- **新增套件**:`laravel/reverb`(PHP)、`intervention/image`(PHP)、`laravel-echo`、`pusher-js`(npm)
|
||||
- **新增 Docker service**:`reverb`(連接 `cfdive-network` 與 `proxy_net`,port 8080 僅內網)
|
||||
- **新增 API 端點**:訊息列表、發送文字、上傳圖片、標記已讀
|
||||
- **新增 API 端點**:訊息列表(`GET /api/bookings/{id}/messages`)、發送文字/圖片(`POST /api/bookings/{id}/messages`)、標記已讀(`POST /api/bookings/{id}/messages/read`)、批次未讀計數(`GET /api/bookings/messages/unread-counts`)
|
||||
- **修改**:`docker-compose.yml`、`config/broadcasting.php`(connection 改為 `reverb`)
|
||||
- **Infrastructure**:NPM 新增 `ws.hank-space.com` Proxy Host(WebSocket 啟用)、DNS A Record
|
||||
- **不影響**:現有預約 API、評價系統、所有已完成模組
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
|
||||
#### Scenario: 合法參與方成功訂閱
|
||||
- **WHEN** 已認證使用者帶有效 Bearer token 連線至 `wss://ws.hank-space.com`,並訂閱 `presence-booking.{id}`
|
||||
- **THEN** `broadcasting/auth` 端點回傳授權成功,使用者加入頻道,頻道廣播 `joining` event(含 `user_id`、`user_type`)
|
||||
- **THEN** `broadcasting/auth` 端點回傳授權成功,使用者加入頻道,頻道廣播 `joining` event(含 `user_id`、`user_type`、`name`)
|
||||
|
||||
#### Scenario: 非參與方訂閱被拒絕
|
||||
- **WHEN** 非該預約參與方嘗試訂閱頻道
|
||||
+10
-10
@@ -10,9 +10,9 @@
|
||||
## 2. Docker 與 Infrastructure 設定
|
||||
|
||||
- [x] 2.1 [後端] 在 `docker-compose.yml` 新增 `reverb` service:複用 `cfdive-platform` image,`command: php artisan reverb:start --host=0.0.0.0 --port=8080 --debug`,連接 `cfdive-network` 與 `proxy_net`,`restart: unless-stopped`,`depends_on: app`
|
||||
- [ ] 2.2 [Infrastructure] 在 DNS 新增 A Record:`ws.hank-space.com` → VPS IP
|
||||
- [ ] 2.3 [Infrastructure] 在 Nginx Proxy Manager 新增 Proxy Host:Domain `ws.hank-space.com`,Forward 至 `reverb:8080`,啟用 WebSocket support,申請 SSL 憑證
|
||||
- [ ] 2.4 [後端] 執行 `docker-compose up --build reverb` 驗證 Reverb 容器啟動正常
|
||||
- [x] 2.2 [Infrastructure] 在 DNS 新增 A Record:`ws.hank-space.com` → VPS IP
|
||||
- [x] 2.3 [Infrastructure] 在 Nginx Proxy Manager 新增 Proxy Host:Domain `ws.hank-space.com`,Forward 至 `reverb:8080`,啟用 WebSocket support,申請 SSL 憑證
|
||||
- [x] 2.4 [後端] 執行 `docker-compose up --build reverb` 驗證 Reverb 容器啟動正常
|
||||
|
||||
## 3. 資料庫
|
||||
|
||||
@@ -69,10 +69,10 @@
|
||||
|
||||
## 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 [手動驗證] 關閉一個視窗,確認另一端顯示「對方已離線」
|
||||
- [x] 9.1 [整合測試] 驗證 `/broadcasting/auth` 端點可存取(不是 404):分別用 member token 與 coach token 送出請求,確認路由已正確註冊且 Sanctum middleware 生效;合法參與方回傳 200,非參與方回傳 403,非 confirmed 狀態回傳 403
|
||||
- [x] 9.2 [整合測試] 驗證 `POST /api/bookings/{id}/messages`:text 訊息成功建立且廣播;image 上傳成功且 URL 可存取;invalid 檔案回傳 422
|
||||
- [x] 9.3 [整合測試] 驗證 `markRead`:己方訊息不更新;對方訊息 `read_at` 被設定;`MessageRead` 廣播觸發
|
||||
- [x] 9.4 [整合測試] 驗證封存:`completed` 預約 POST 訊息回傳 403,GET 歷史正常回傳
|
||||
- [x] 9.5 [手動驗證] 開兩個瀏覽器分別登入 Member 與 Coach,確認訊息即時雙向傳達、在線狀態顯示正確、已讀回執正常觸發
|
||||
- [x] 9.6 [手動驗證] 測試圖片訊息:上傳圖片後對方即時看到圖片
|
||||
- [x] 9.7 [手動驗證] 關閉一個視窗,確認另一端顯示「對方已離線」
|
||||
@@ -0,0 +1,132 @@
|
||||
### 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}/messages`,body 為 `{ type: 'text', content: '...' }`
|
||||
- **THEN** 系統建立 `BookingMessage` 記錄,回傳 201,並透過 WebSocket 廣播 `MessageSent` event(含 `id`、`sender_id`、`sender_type`、`type`、`content`、`created_at`)
|
||||
|
||||
#### Scenario: 預約非 confirmed 狀態時不可發送
|
||||
- **WHEN** 預約 status 不為 `confirmed`
|
||||
- **THEN** 系統回傳 403,告知訊息功能僅在預約確認期間開放
|
||||
|
||||
#### Scenario: 非參與方不可發送
|
||||
- **WHEN** 非該預約的 Member 或對應 Provider 嘗試發送訊息
|
||||
- **THEN** 系統回傳 403 Forbidden
|
||||
|
||||
#### Scenario: 訊息內容不可為空
|
||||
- **WHEN** `content` 為空字串或未提供
|
||||
- **THEN** 系統回傳 422,`errors.content` 說明必填
|
||||
|
||||
### Requirement: 發送圖片訊息
|
||||
Member 與 Provider SHALL 能在 `confirmed` 預約中上傳圖片作為訊息。圖片透過 HTTP multipart POST 上傳至 Laravel Storage,系統建立 `type: image` 的 `BookingMessage` 並廣播圖片 URL。
|
||||
|
||||
#### Scenario: 成功上傳並發送圖片
|
||||
- **WHEN** 已認證使用者對 `confirmed` 預約送出 `POST /api/bookings/{id}/messages`,`Content-Type: multipart/form-data`,`type: image`,`file` 為有效圖片(jpg/png/gif/webp,≤ 10MB)
|
||||
- **THEN** 系統儲存圖片至 Storage,建立 `BookingMessage`(`content` 存圖片 URL),廣播 `MessageSent` event,回傳 201
|
||||
|
||||
#### Scenario: 不支援的檔案類型被拒絕
|
||||
- **WHEN** 上傳的 `file` 非 jpg/png/gif/webp
|
||||
- **THEN** 系統回傳 422,`errors.file` 說明僅支援圖片格式
|
||||
|
||||
#### Scenario: 超過大小限制被拒絕
|
||||
- **WHEN** 圖片檔案大小超過 10MB
|
||||
- **THEN** 系統回傳 422,`errors.file` 說明大小限制
|
||||
|
||||
### Requirement: 讀取訊息歷史
|
||||
Member 與 Provider SHALL 能透過 `GET /api/bookings/{id}/messages` 取得該預約的完整訊息歷史,按 `created_at` 升序排列。
|
||||
|
||||
#### Scenario: 成功取得訊息歷史
|
||||
- **WHEN** 已認證的參與方送出 `GET /api/bookings/{id}/messages`
|
||||
- **THEN** 系統回傳訊息陣列,每筆包含 `id`、`sender_id`、`sender_type`、`type`、`content`、`read_at`、`created_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 為 `rejected`、`expired`、`member_cancelled` 或 `provider_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_type`、`last_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` 資料表(`NewBookingMessageNotification`,database 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** 系統建立一則 `Notification`,`tag: '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(只對「對方」的訊息觸發)
|
||||
@@ -31,10 +31,19 @@ Member SHALL 能選擇一個開放時段送出預約,系統記錄價格快照
|
||||
- `confirmed` → `member_cancelled`(Member 取消)
|
||||
- `confirmed` → `provider_cancelled`(Provider 取消)
|
||||
|
||||
各狀態對應訊息頻道語義:
|
||||
- `confirmed`:`presence-booking.{id}` 頻道開放,可讀寫訊息
|
||||
- `completed`:頻道關閉,訊息歷史封存唯讀
|
||||
- 其餘狀態(`pending`、`rejected`、`expired`、`member_cancelled`、`provider_cancelled`):無訊息頻道
|
||||
|
||||
#### Scenario: 非法狀態轉換被拒絕
|
||||
- **WHEN** 任何角色嘗試執行上述以外的狀態轉換
|
||||
- **THEN** 系統回傳 422,說明當前狀態不允許此操作
|
||||
|
||||
#### Scenario: confirmed 轉 completed 時封存訊息頻道
|
||||
- **WHEN** Scheduler 將 `confirmed` 預約轉為 `completed`
|
||||
- **THEN** 對應 `presence-booking.{id}` 頻道不再授權新連線;現有訊息歷史保留,後續 POST 訊息回傳 403
|
||||
|
||||
### Requirement: Provider 確認或拒絕預約
|
||||
Provider SHALL 能對自己課程的 `pending` 預約執行確認或拒絕。確認時才真正佔用時段名額。
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
### Requirement: 訂閱預約 Presence Channel
|
||||
已認證的 Member 與 Provider SHALL 能透過 Laravel Echo 訂閱 `presence-booking.{booking_id}` 頻道,訂閱時系統 SHALL 驗證使用者確為該預約的參與方。
|
||||
|
||||
#### Scenario: 合法參與方成功訂閱
|
||||
- **WHEN** 已認證使用者帶有效 Bearer token 連線至 `wss://ws.hank-space.com`,並訂閱 `presence-booking.{id}`
|
||||
- **THEN** `broadcasting/auth` 端點回傳授權成功,使用者加入頻道,頻道廣播 `joining` event(含 `user_id`、`user_type`、`name`)
|
||||
|
||||
#### Scenario: 非參與方訂閱被拒絕
|
||||
- **WHEN** 非該預約參與方嘗試訂閱頻道
|
||||
- **THEN** `broadcasting/auth` 回傳 403,連線不建立
|
||||
|
||||
#### Scenario: 預約非 confirmed 狀態時頻道不授權
|
||||
- **WHEN** 預約 status 不為 `confirmed`,任何使用者嘗試訂閱
|
||||
- **THEN** `broadcasting/auth` 回傳 403
|
||||
|
||||
### Requirement: 在線狀態感知
|
||||
訂閱 Presence Channel 後,系統 SHALL 回傳目前在線成員清單。任一方加入或離開時 SHALL 廣播對應事件。
|
||||
|
||||
#### Scenario: 取得目前在線清單
|
||||
- **WHEN** 使用者成功加入 `presence-booking.{id}` 頻道
|
||||
- **THEN** Echo `.here()` callback 收到目前在線的使用者清單(含 `user_id`、`user_type`、`name`)
|
||||
|
||||
#### Scenario: 對方加入頻道
|
||||
- **WHEN** 對方(Member 或 Provider)開啟訊息視窗並成功訂閱頻道
|
||||
- **THEN** 已在頻道中的使用者收到 `.joining()` event,可顯示「對方已上線」
|
||||
|
||||
#### Scenario: 對方離開頻道
|
||||
- **WHEN** 對方關閉訊息視窗或斷線
|
||||
- **THEN** 仍在頻道中的使用者收到 `.leaving()` event,可顯示「對方已離線」
|
||||
|
||||
### Requirement: 已讀回執顯示
|
||||
前端 SHALL 依據 `MessageRead` event 更新訊息的已讀狀態,顯示「已讀」標記。
|
||||
|
||||
#### Scenario: 己方訊息被對方讀取後顯示已讀
|
||||
- **WHEN** 頻道收到 `MessageRead` event,`reader_type` 為對方,`last_read_message_id` >= 某訊息 id
|
||||
- **THEN** 前端將該訊息及之前的己方訊息顯示「已讀」標記
|
||||
|
||||
#### Scenario: 對方不在頻道時訊息顯示未讀
|
||||
- **WHEN** 對方未訂閱頻道(離線)
|
||||
- **THEN** 新發送的訊息 `read_at` 為 null,顯示「未讀」狀態
|
||||
|
||||
### Requirement: 未讀訊息計數
|
||||
系統 SHALL 在預約列表頁顯示每個預約的未讀訊息數量角標。
|
||||
|
||||
#### Scenario: 有未讀訊息時顯示角標
|
||||
- **WHEN** Member 或 Provider 進入預約列表,某預約有 `read_at IS NULL` 且 `sender_type` 為對方的訊息
|
||||
- **THEN** 對應預約卡片顯示未讀數量角標(數字或紅點)
|
||||
|
||||
#### Scenario: 進入訊息視窗後角標清除
|
||||
- **WHEN** 使用者進入該預約的訊息視窗,送出 `POST /api/bookings/{id}/messages/read`
|
||||
- **THEN** 未讀角標消失
|
||||
Reference in New Issue
Block a user