feat:實作評價系統 — 匿名評價、有幫助投票、手動完成預約
後端: - 新增 reviews / review_edits / review_votes migration(含索引) - Review / ReviewEdit / ReviewVote Model - ReviewController:評價 CRUD、資格驗證(completed booking)、rating 即時重算 - toggleHelpful:Member 限定、GREATEST 原子防負、DB transaction 同步 - AdminReviewController:全量列表、刪除(含重算) - AdminBookingController:全量列表、手動標記 completed - ProviderBookingController 新增 complete 方法(教練手動完成預約) - DevelopmentSeeder:快速重建測試資料(admin/coach/member + offers + bookings) - EnsureAdmin middleware 正式納入 bootstrap/app.php - Nginx server_name 加入 cfdive.local 前端: - 課程詳情頁加入評價區塊(星等分布、排序切換、撰寫/修改/刪除、有幫助 Toggle) - Coach Portal 新增「課程評價」頁(只讀,依課程分組) - Coach 預約管理加入「完成」按鈕 - Admin 新增「預約管理」頁(標記完成)、「評價管理」頁(刪除) - Admin / Coach Navbar 新增對應連結 OpenSpec: - review-system change 歸檔至 archive/2026-05-12-review-system - 新增 specs/review-lifecycle 與 specs/review-voting 主規格 - review-voting spec 補充 Member 限定與 GREATEST 原子更新說明 Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
### Requirement: Member 新增評價
|
||||
已完成特定課程的 Member SHALL 能對該課程留下一次評價(星等 + 文字)。
|
||||
|
||||
#### Scenario: 成功新增評價
|
||||
- **WHEN** 已登入 Member 送出 `POST /api/member/reviews`,包含 `diving_offer_id`、`rating`(1–5 整數)、`comment`(非空字串),且系統查詢 `bookings JOIN course_schedules` 找到至少一筆 `member_id = X AND diving_offer_id = Y AND status = 'completed'`
|
||||
- **THEN** 系統建立 Review,回傳 201
|
||||
|
||||
#### Scenario: 未完成課程不可評價(資格驗證)
|
||||
- **WHEN** Member 送出評價,但 `bookings` 中不存在任何 `status = 'completed'` 且對應 `diving_offer_id` 的紀錄
|
||||
- **THEN** 系統回傳 **403**,message:「須完成此課程後才能評價」
|
||||
|
||||
#### Scenario: 每門課只能評一次
|
||||
- **WHEN** `reviews` 中已存在同一 `member_id` + `diving_offer_id` 的紀錄
|
||||
- **THEN** 系統回傳 **422**(非 409),message:「已評價,如需修改請使用編輯功能」
|
||||
|
||||
#### Scenario: 星等範圍驗證
|
||||
- **WHEN** `rating` 不在 1–5 之間
|
||||
- **THEN** 系統回傳 422
|
||||
|
||||
### Requirement: 評價後即時更新課程統計
|
||||
Member 新增、修改或刪除評價時,系統 SHALL 在同一 DB transaction 內重算 `diving_offers.rating` 與 `reviews`。Provider 或 Admin 手動標記 booking 為 completed 亦同樣觸發評價資格。
|
||||
|
||||
#### Scenario: 新增評價後重算
|
||||
- **WHEN** Review 建立成功
|
||||
- **THEN** `diving_offers.rating = ROUND(AVG(rating), 1)`、`diving_offers.reviews = COUNT(*)` 即時更新
|
||||
|
||||
#### Scenario: 刪除評價後重算
|
||||
- **WHEN** Review 被 Member 或 Admin 刪除
|
||||
- **THEN** `rating` 與 `reviews` 在同一 transaction 內重算;若剩餘 0 筆評價,`rating = 0`、`reviews = 0`
|
||||
|
||||
### Requirement: Member 修改評價
|
||||
Member SHALL 能修改自己的評價,系統保留最近一次修改前的版本並標記已修改。
|
||||
|
||||
#### Scenario: 成功修改評價
|
||||
- **WHEN** Member 送出 `PUT /api/member/reviews/{id}`,包含新的 `rating` 或 `comment`
|
||||
- **THEN** 系統將舊版 `rating` / `comment` 寫入 `review_edits`(若已存在則覆蓋);更新 Review 內容;將 `is_edited = true`;重算課程統計
|
||||
|
||||
#### Scenario: 只能修改自己的評價
|
||||
- **WHEN** Member 嘗試修改他人的評價
|
||||
- **THEN** 系統回傳 403
|
||||
|
||||
### Requirement: Member 刪除評價
|
||||
Member SHALL 能刪除自己的評價,Admin SHALL 能刪除任何評價。
|
||||
|
||||
#### Scenario: Member 刪除自己的評價
|
||||
- **WHEN** Member 送出 `DELETE /api/member/reviews/{id}`
|
||||
- **THEN** 系統刪除 Review 及對應的 review_edits / review_votes;重算課程統計
|
||||
|
||||
#### Scenario: Admin 刪除任意評價
|
||||
- **WHEN** Admin 送出 `DELETE /api/admin/reviews/{id}`
|
||||
- **THEN** 系統刪除 Review 及關聯資料;重算課程統計
|
||||
|
||||
#### Scenario: 只能刪除自己的評價(非 Admin)
|
||||
- **WHEN** 非 Admin Member 嘗試刪除他人評價
|
||||
- **THEN** 系統回傳 403
|
||||
|
||||
### Requirement: 評價公開顯示(匿名)
|
||||
任何人(含未登入)SHALL 能查看課程評價列表,評價人統一顯示為「匿名潛水者」。Provider 在 Coach Portal 亦可查看自己課程的評價(只讀)。
|
||||
|
||||
#### Scenario: 取得評價列表(含 summary)
|
||||
- **WHEN** 任何人送出 `GET /api/diving-offers/{id}/reviews?sort=helpful|rating|newest`
|
||||
- **THEN** 系統回傳 `summary`(平均星等、總數、1–5 星分布)與 `reviews` 列表;`reviewer_name` 一律為「匿名潛水者」;已登入 Member 額外回傳 `is_mine`;未登入 `has_voted` 固定為 `false`、`is_mine` 欄位省略
|
||||
|
||||
#### Scenario: 三種排序
|
||||
- **WHEN** `sort=helpful`(預設)
|
||||
- **THEN** 依 `helpful_count DESC, created_at DESC` 排序
|
||||
- **WHEN** `sort=rating`
|
||||
- **THEN** 依 `rating DESC, created_at DESC` 排序
|
||||
- **WHEN** `sort=newest`
|
||||
- **THEN** 依 `created_at DESC` 排序
|
||||
|
||||
### Requirement: 課程完成標記(評價資格觸發)
|
||||
Provider 或 Admin SHALL 能手動將 confirmed 預約標記為 completed,讓 Member 可立即評價,不需等待排程。
|
||||
|
||||
#### Scenario: Provider 手動完成
|
||||
- **WHEN** Provider 送出 `PUT /api/provider/bookings/{id}/complete`,Booking status 為 `confirmed`
|
||||
- **THEN** Booking status 改為 `completed`,Member 即可對該課程送出評價
|
||||
|
||||
#### Scenario: Admin 手動完成
|
||||
- **WHEN** Admin 送出 `PUT /api/admin/bookings/{id}/complete`,Booking status 為 `confirmed`
|
||||
- **THEN** Booking status 改為 `completed`
|
||||
@@ -0,0 +1,33 @@
|
||||
### Requirement: Member 對評價投「有幫助」票
|
||||
已登入 **Member**(role = member)SHALL 能對評價投「有幫助」票,可取消,不可重複投票。Provider 與 Admin 不可投票。
|
||||
|
||||
#### Scenario: 成功投票
|
||||
- **WHEN** 已登入 Member 送出 `POST /api/reviews/{id}/helpful`,且尚未對此評價投票
|
||||
- **THEN** 系統建立 ReviewVote,`reviews.helpful_count + 1`,回傳目前 `helpful_count`
|
||||
|
||||
#### Scenario: 取消投票(Toggle)
|
||||
- **WHEN** 已登入 Member 再次送出 `POST /api/reviews/{id}/helpful`,且已投過票
|
||||
- **THEN** 系統刪除 ReviewVote,`reviews.helpful_count` 以 `GREATEST(helpful_count - 1, 0)` 原子更新,回傳目前 `helpful_count`
|
||||
|
||||
#### Scenario: 未登入不可投票
|
||||
- **WHEN** 未登入使用者嘗試投票
|
||||
- **THEN** 系統回傳 401
|
||||
|
||||
#### Scenario: 非 Member 角色不可投票
|
||||
- **WHEN** Provider 或 Admin 嘗試投票
|
||||
- **THEN** 系統回傳 403,message:「只有會員可以投票」
|
||||
|
||||
#### Scenario: 不可對自己的評價投票
|
||||
- **WHEN** Member 嘗試對自己撰寫的評價投票
|
||||
- **THEN** 系統回傳 422,告知不可對自己的評價投票
|
||||
|
||||
### Requirement: 投票狀態隨評價一同回傳
|
||||
已登入 Member 查看評價列表時,系統 SHALL 回傳當前用戶對每筆評價的投票狀態。
|
||||
|
||||
#### Scenario: 已登入查看列表
|
||||
- **WHEN** 已登入 Member 送出 `GET /api/diving-offers/{id}/reviews`
|
||||
- **THEN** 每筆評價包含 `has_voted: true/false`,供前端渲染「有幫助」按鈕狀態
|
||||
|
||||
#### Scenario: 未登入查看列表
|
||||
- **WHEN** 未登入使用者送出 `GET /api/diving-offers/{id}/reviews`
|
||||
- **THEN** 每筆評價的 `has_voted` 固定為 `false`
|
||||
Reference in New Issue
Block a user