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,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-11
|
||||
@@ -0,0 +1,3 @@
|
||||
# review-system
|
||||
|
||||
課程評價系統:完課後留評、匿名顯示、有幫助投票、三種排序
|
||||
@@ -0,0 +1,290 @@
|
||||
## Context
|
||||
|
||||
CFDivePlatform 已完成預約系統,`bookings.status = completed` 是評價資格的天然觸發點。`diving_offers` 已有 `rating`(float)與 `reviews`(int)欄位,目前為假資料,本次將接管這兩個欄位,改由真實評價計算。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Member 完課後可留評(1–5 星 + 文字),每門課一次
|
||||
- 修改評價留下 is_edited 標記與最近一筆舊版備份
|
||||
- 有幫助投票(Toggle,防重複,不可投自己)
|
||||
- 公開列表三種排序,評價人匿名
|
||||
|
||||
**Non-Goals:**
|
||||
- 教練回覆評價(未來功能)
|
||||
- 多維度評分(服務、設備等)
|
||||
- 評價檢舉/審核流程(Admin 直接刪除即可)
|
||||
- 分頁(MVP 全量回傳,課程評價數量有限)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 決策一:資料表結構
|
||||
|
||||
```
|
||||
reviews
|
||||
id, diving_offer_id, member_id
|
||||
rating (tinyint 1-5)
|
||||
comment (text)
|
||||
helpful_count (int DEFAULT 0)
|
||||
is_edited (boolean DEFAULT false)
|
||||
created_at, updated_at
|
||||
UNIQUE(member_id, diving_offer_id)
|
||||
索引: (diving_offer_id, helpful_count DESC)
|
||||
(diving_offer_id, rating DESC)
|
||||
(diving_offer_id, created_at DESC)
|
||||
|
||||
review_edits
|
||||
id, review_id (UNIQUE FK), old_rating, old_comment, edited_at
|
||||
|
||||
review_votes
|
||||
id, review_id, member_id, created_at
|
||||
UNIQUE(review_id, member_id)
|
||||
```
|
||||
|
||||
### 決策二:rating 重算時機與方式
|
||||
|
||||
在 ReviewController 的 create / update / destroy 內,與 Review 操作同一 DB transaction:
|
||||
|
||||
```php
|
||||
$stats = Review::where('diving_offer_id', $offerId)
|
||||
->selectRaw('ROUND(AVG(rating), 1) as avg_rating, COUNT(*) as total')
|
||||
->first();
|
||||
|
||||
DivingOffer::where('id', $offerId)->update([
|
||||
'rating' => $stats->total > 0 ? $stats->avg_rating : 0,
|
||||
'reviews' => $stats->total,
|
||||
]);
|
||||
```
|
||||
|
||||
**放棄**:Observer 模式 → 同樣是即時,但執行點分散難追蹤;排程重算 → 有延遲。
|
||||
|
||||
### 決策三:review_edits 覆蓋策略
|
||||
|
||||
`review_edits` 的 `review_id` 為 UNIQUE,每次修改用 `updateOrCreate`:
|
||||
|
||||
```php
|
||||
ReviewEdit::updateOrCreate(
|
||||
['review_id' => $review->id],
|
||||
['old_rating' => $review->rating, 'old_comment' => $review->comment, 'edited_at' => now()]
|
||||
);
|
||||
```
|
||||
|
||||
**理由**:用戶需求是「知道改過就好」,無需完整歷史,一筆足夠。
|
||||
|
||||
### 決策四:評價資格驗證具體邏輯
|
||||
|
||||
```php
|
||||
$eligible = Booking::where('member_id', $memberId)
|
||||
->whereHas('schedule', fn($q) => $q->where('diving_offer_id', $offerId))
|
||||
->where('status', BookingStatus::Completed)
|
||||
->exists();
|
||||
|
||||
if (!$eligible) {
|
||||
return response()->json(['status' => false, 'message' => '須完成此課程後才能評價'], 403);
|
||||
}
|
||||
```
|
||||
|
||||
**規則**:有任意一筆 `completed` booking(不限場次)即可評價。已評過同一課程則回傳 422(非 409,因為提供的是「操作不合法」而非「資源衝突」)。
|
||||
|
||||
---
|
||||
|
||||
### 決策五:有幫助投票 Toggle — **transaction 為強制規範**
|
||||
|
||||
`POST /api/reviews/{id}/helpful` 同一端點,**整個操作必須在 DB transaction 內**:
|
||||
|
||||
```php
|
||||
DB::transaction(function () use ($review, $memberId) {
|
||||
$vote = ReviewVote::where('review_id', $review->id)
|
||||
->where('member_id', $memberId)->first();
|
||||
|
||||
if ($vote) {
|
||||
$vote->delete();
|
||||
// 單一 SQL 原子操作,避免 decrement + check 兩次 SQL 的競態風險:
|
||||
DB::table('reviews')
|
||||
->where('id', $review->id)
|
||||
->update(['helpful_count' => DB::raw('GREATEST(helpful_count - 1, 0)')]);
|
||||
} else {
|
||||
ReviewVote::create(['review_id' => $review->id, 'member_id' => $memberId]);
|
||||
$review->increment('helpful_count');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**理由**:`decrement` 後再 `if ($review->helpful_count < 0)` 是兩次 SQL,即使在 transaction 內,高並發下兩筆寫入之間仍可能讀到中間負值。`GREATEST(helpful_count - 1, 0)` 是單一原子 SQL,天然防負。
|
||||
|
||||
**理由**:ReviewVote 與 helpful_count 必須原子性同步,transaction 外任一失敗都會造成計數與投票紀錄不一致。這是**強制要求**,非建議。
|
||||
|
||||
**放棄**:分開 POST(投票)和 DELETE(取消)端點 → Toggle 在前端更直覺,一個按鈕即可。
|
||||
|
||||
---
|
||||
|
||||
### 決策六:匿名化方式
|
||||
|
||||
回傳 `reviewer_name = '匿名潛水者'`(固定字串),不揭露 member_id。
|
||||
|
||||
**理由**:最簡單、最安全。若未來需要識別(如「你評過這門課」),透過 `is_mine` flag 處理,不需暴露身份。
|
||||
|
||||
---
|
||||
|
||||
### 決策七:has_voted / is_mine 注入規則
|
||||
|
||||
列表 API 依登入狀態差異化回傳:
|
||||
|
||||
| 欄位 | 未登入 | 已登入 Member |
|
||||
|------|--------|---------------|
|
||||
| `reviewer_name` | `匿名潛水者` | `匿名潛水者` |
|
||||
| `has_voted` | `false`(固定,不省略) | 批次查詢 review_votes 後注入 |
|
||||
| `is_mine` | **省略**(不出現在 response) | `true` 或 `false` |
|
||||
|
||||
`is_mine` 未登入時省略(非 false)的理由:前端可用 `'is_mine' in review` 判斷是否登入,而非 `review.is_mine === true`,語意更清晰。
|
||||
|
||||
批次查詢避免 N+1:
|
||||
```php
|
||||
$myVotes = $user ? ReviewVote::where('member_id', $user->id)
|
||||
->whereIn('review_id', $reviews->pluck('id'))
|
||||
->pluck('review_id')->flip() : collect();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 決策八:Admin 評價列表範圍
|
||||
|
||||
`GET /api/admin/reviews` MVP 回傳全量(依 `created_at DESC`),不做分頁或 offer 篩選。
|
||||
|
||||
**理由**:Admin Panel 評價管理目的是快速找到問題評論並刪除,全量夠用。若日後評價量大,加 `?offer_id=` 篩選參數即可,不是 breaking change。
|
||||
|
||||
---
|
||||
|
||||
### 決策九:Admin DELETE 必須觸發 rating 重算
|
||||
|
||||
Admin `DELETE /api/admin/reviews/{id}` 與 Member 刪除共用同一個重算邏輯(抽成 private method `recalculateOfferRating($offerId)`),兩個 Controller 都呼叫,確保不遺漏。
|
||||
|
||||
```php
|
||||
private function recalculateOfferRating(int $offerId): void
|
||||
{
|
||||
$stats = Review::where('diving_offer_id', $offerId)
|
||||
->selectRaw('ROUND(AVG(rating), 1) as avg_rating, COUNT(*) as total')
|
||||
->first();
|
||||
|
||||
DivingOffer::where('id', $offerId)->update([
|
||||
'rating' => $stats->total > 0 ? $stats->avg_rating : 0,
|
||||
'reviews' => $stats->total,
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
## 資料表索引
|
||||
|
||||
```
|
||||
reviews:
|
||||
idx_offer_helpful (diving_offer_id, helpful_count DESC) ← 預設排序
|
||||
idx_offer_rating (diving_offer_id, rating DESC) ← 高分排序
|
||||
idx_offer_newest (diving_offer_id, created_at DESC) ← 最新排序
|
||||
idx_member_offer (member_id, diving_offer_id) ← 資格驗證
|
||||
|
||||
review_votes:
|
||||
UNIQUE(review_id, member_id) ← 防重複投票
|
||||
```
|
||||
|
||||
## API 路由總覽
|
||||
|
||||
```
|
||||
公開
|
||||
GET /api/diving-offers/{id}/reviews?sort=helpful|rating|newest
|
||||
|
||||
Member (auth:sanctum)
|
||||
POST /api/member/reviews
|
||||
PUT /api/member/reviews/{id}
|
||||
DELETE /api/member/reviews/{id}
|
||||
POST /api/reviews/{id}/helpful ← Toggle,需登入
|
||||
|
||||
Admin (auth:sanctum)
|
||||
GET /api/admin/reviews
|
||||
DELETE /api/admin/reviews/{id}
|
||||
```
|
||||
|
||||
## Response Schema
|
||||
|
||||
### GET /api/diving-offers/{id}/reviews
|
||||
|
||||
**`summary.distribution` 計算方式**:每次請求動態 `GROUP BY rating COUNT(*)`,不存入資料表。
|
||||
|
||||
```php
|
||||
$distribution = Review::where('diving_offer_id', $offerId)
|
||||
->selectRaw('rating, COUNT(*) as count')
|
||||
->groupBy('rating')
|
||||
->pluck('count', 'rating');
|
||||
|
||||
// 補齊 1–5 全部 key(避免前端處理 undefined):
|
||||
$dist = collect([1=>0, 2=>0, 3=>0, 4=>0, 5=>0])
|
||||
->merge($distribution);
|
||||
```
|
||||
|
||||
**理由**:`diving_offers` 只存 `rating`(avg)和 `reviews`(count),分布需動態查詢。不另存欄位,因為每次評價變動都需同步五個計數,維護成本高於查詢成本(評價數量有限)。
|
||||
|
||||
```json
|
||||
{
|
||||
"status": true,
|
||||
"data": {
|
||||
"summary": {
|
||||
"average": 4.5,
|
||||
"total": 12,
|
||||
"distribution": { "5": 7, "4": 3, "3": 1, "2": 1, "1": 0 }
|
||||
},
|
||||
"reviews": [
|
||||
{
|
||||
"id": 1,
|
||||
"reviewer_name": "匿名潛水者",
|
||||
"rating": 5,
|
||||
"comment": "課程非常棒!",
|
||||
"helpful_count": 8,
|
||||
"is_edited": false,
|
||||
"created_at": "2026-05-12T10:00:00Z",
|
||||
"has_voted": false,
|
||||
"is_mine": true // 僅登入用戶才有此欄位
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Codes 完整定義
|
||||
|
||||
**POST /api/member/reviews(新增評價)**
|
||||
|
||||
| 情況 | HTTP | message |
|
||||
|------|------|---------|
|
||||
| 未完成課程 | 403 | 須完成此課程後才能評價 |
|
||||
| 已評過同課程 | 422 | 已評價,如需修改請使用編輯功能 |
|
||||
| rating 不在 1–5 | 422 | rating 須為 1–5 的整數 |
|
||||
| comment 為空 | 422 | 評論內容不可為空 |
|
||||
|
||||
**PUT /api/member/reviews/{id}(修改評價)**
|
||||
|
||||
| 情況 | HTTP | message |
|
||||
|------|------|---------|
|
||||
| 評價不存在 | 404 | 找不到此評價 |
|
||||
| 非本人評價 | 403 | 無權修改此評價 |
|
||||
| rating 不在 1–5 | 422 | rating 須為 1–5 的整數 |
|
||||
| comment 為空字串 | 422 | 評論內容不可為空 |
|
||||
|
||||
**DELETE /api/member/reviews/{id}(刪除評價)**
|
||||
|
||||
| 情況 | HTTP | message |
|
||||
|------|------|---------|
|
||||
| 評價不存在 | 404 | 找不到此評價 |
|
||||
| 非本人評價 | 403 | 無權刪除此評價 |
|
||||
|
||||
**POST /api/reviews/{id}/helpful(投票)**
|
||||
|
||||
| 情況 | HTTP | message |
|
||||
|------|------|---------|
|
||||
| 未登入 | 401 | Unauthenticated |
|
||||
| 投自己的評價 | 422 | 不可對自己的評價投票 |
|
||||
| 評價不存在 | 404 | 找不到此評價 |
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **helpful_count 與 review_votes 同步**:已在決策五明確要求 transaction,此風險已關閉
|
||||
- **全量回傳無分頁**:若單一課程累積大量評價(>200),效能下降。MVP 可接受,日後加 cursor pagination
|
||||
- **匿名化無法「回溯」**:若未來需要顯示名字,需 migration 補欄位。目前決策鎖定匿名,風險低
|
||||
@@ -0,0 +1,42 @@
|
||||
## Why
|
||||
|
||||
預約系統完成後,平台已能撮合教練與學員完成課程。但 `diving_offers.rating` 與 `reviews` 兩個欄位目前是假資料,平台缺乏真實評價機制,Member 瀏覽課程時無法參考其他人的體驗。評價系統是提升平台信任度與課程品質的關鍵閉環。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增 `reviews` 資料表:Member 對完成的課程留下星等(1–5)與文字評論
|
||||
- 新增 `review_edits` 資料表:記錄最近一次修改前的舊版本(一評價一筆上限)
|
||||
- 新增 `review_votes` 資料表:Member 對評價投「有幫助」票(可取消,防重複)
|
||||
- `diving_offers.rating` / `.reviews` 在新增、修改、刪除評價時即時重算
|
||||
- 評價公開顯示,評價人統一匿名為「匿名潛水者」
|
||||
- 三種排序切換:最多幫助(預設)/ 最高分 / 最新
|
||||
- Member 可修改與刪除自己的評價;Admin 可刪除任何評價
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `review-lifecycle`:評價的建立(資格驗證、一課一評)、修改(is_edited 標記 + 舊版備份)、刪除(Member 本人或 Admin)、rating 即時重算
|
||||
- `review-voting`:Member 登入後對評價投「有幫助」票,可取消;依 `helpful_count` 排序為預設
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
(無)
|
||||
|
||||
## Impact
|
||||
|
||||
**後端**
|
||||
- 新增 Migration:`reviews`、`review_edits`、`review_votes`
|
||||
- 新增 Model:`Review`、`ReviewEdit`、`ReviewVote`
|
||||
- 新增 Controller:`ReviewController`(Member)、`AdminReviewController`(Admin)
|
||||
- 更新 `routes/api.php`:公開列表、Member 評價 CRUD + 投票、Admin 刪除
|
||||
- 更新 `DivingOffer` Model:加入 `hasMany Review` 關聯
|
||||
|
||||
**前端**
|
||||
- 新增課程詳情頁評價區塊:星等分布、評價列表、排序切換、「有幫助」按鈕
|
||||
- 新增 Member 評價表單:完課後可寫評、已評可修改
|
||||
- 新增 `frontend/src/api/reviewApi.js`
|
||||
- Admin Panel 新增評價管理頁(刪除問題評論)
|
||||
|
||||
**資料庫**
|
||||
- 三張新資料表,`diving_offers` 現有 `rating` / `reviews` 欄位語意從假資料改為真實計算值
|
||||
@@ -0,0 +1,72 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### 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`。
|
||||
|
||||
#### 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 能查看課程評價列表,評價人統一顯示為「匿名潛水者」。
|
||||
|
||||
#### 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` 排序
|
||||
@@ -0,0 +1,31 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Member 對評價投「有幫助」票
|
||||
已登入 Member SHALL 能對評價投「有幫助」票,可取消,不可重複投票。
|
||||
|
||||
#### Scenario: 成功投票
|
||||
- **WHEN** 已登入 Member 送出 `POST /api/reviews/{id}/helpful`,且尚未對此評價投票
|
||||
- **THEN** 系統建立 ReviewVote,`reviews.helpful_count + 1`,回傳目前 `helpful_count`
|
||||
|
||||
#### Scenario: 取消投票
|
||||
- **WHEN** 已登入 Member 再次送出 `POST /api/reviews/{id}/helpful`,且已投過票
|
||||
- **THEN** 系統刪除 ReviewVote,`reviews.helpful_count - 1`,回傳目前 `helpful_count`(Toggle 行為)
|
||||
|
||||
#### Scenario: 未登入不可投票
|
||||
- **WHEN** 未登入使用者嘗試投票
|
||||
- **THEN** 系統回傳 401
|
||||
|
||||
#### 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`
|
||||
@@ -0,0 +1,73 @@
|
||||
## 1. 資料庫層
|
||||
|
||||
- [x] 1.1 [後端] 建立 Migration `create_reviews_table`:欄位含 `diving_offer_id`、`member_id`、`rating` (tinyint)、`comment` (text)、`helpful_count` (int)、`is_edited` (boolean);UNIQUE(member_id, diving_offer_id);加索引 `(diving_offer_id, helpful_count)`、`(diving_offer_id, rating)`、`(diving_offer_id, created_at)`
|
||||
- [x] 1.2 [後端] 建立 Migration `create_review_edits_table`:欄位含 `review_id` (UNIQUE FK)、`old_rating`、`old_comment`、`edited_at`
|
||||
- [x] 1.3 [後端] 建立 Migration `create_review_votes_table`:欄位含 `review_id`、`member_id`;UNIQUE(review_id, member_id)
|
||||
- [x] 1.4 [後端] 執行 Migration,確認三張資料表與索引正確
|
||||
|
||||
## 2. Model 層
|
||||
|
||||
- [x] 2.1 [後端] 建立 `app/Models/Review.php`:fillable、casts、關聯(belongsTo DivingOffer / belongsTo User as member、hasOne ReviewEdit、hasMany ReviewVote)
|
||||
- [x] 2.2 [後端] 建立 `app/Models/ReviewEdit.php`:fillable、belongsTo Review
|
||||
- [x] 2.3 [後端] 建立 `app/Models/ReviewVote.php`:fillable、belongsTo Review / belongsTo User as member
|
||||
- [x] 2.4 [後端] 在 `DivingOffer` Model 新增 `hasMany Review` 關聯
|
||||
|
||||
## 3. Member 評價 API
|
||||
|
||||
- [x] 3.1 [後端] 建立 `app/Http/Controllers/API/ReviewController.php`:
|
||||
- 私有方法 `recalculateOfferRating(int $offerId)`:重算 AVG(rating) 與 COUNT(*),並 UPDATE diving_offers,**必須在 DB transaction 內被呼叫**
|
||||
- `store`:資格驗證(`bookings JOIN course_schedules WHERE status=completed AND diving_offer_id=X`,否則 403)→ 重複評價檢查(422)→ DB transaction 建立 Review + 呼叫 recalculate
|
||||
- `update`:所有權驗證(他人 403)→ DB transaction:updateOrCreate review_edits(覆蓋舊版)→ 更新 Review 內容 + is_edited=true → 呼叫 recalculate
|
||||
- `destroy`:所有權驗證(他人 403)→ DB transaction:刪除 Review(cascade edits/votes)→ 呼叫 recalculate
|
||||
- [x] 3.2 [後端] 在 `routes/api.php` 新增 `/member/reviews` 路由群組(POST / PUT /{id} / DELETE /{id})
|
||||
|
||||
## 4. 有幫助投票 API
|
||||
|
||||
- [x] 4.1 [後端] 在 `ReviewController` 新增 `toggleHelpful` 方法:不可投自己(422)→ **整個 toggle 在 DB transaction 內**:查 ReviewVote → 有則 delete + `DB::raw('GREATEST(helpful_count - 1, 0)')` 原子更新(禁止兩段式 decrement+check);無則 create + increment
|
||||
- [x] 4.2 [後端] 在 `routes/api.php` 新增 `POST /reviews/{id}/helpful` 路由(auth:sanctum)
|
||||
|
||||
## 5. 公開評價列表 API
|
||||
|
||||
- [x] 5.1 [後端] 在 `ReviewController` 新增 `publicList` 方法:
|
||||
- 回傳 `summary`(AVG、COUNT、1–5 星分布):分布用 `GROUP BY rating COUNT(*)` 動態查詢並補齊 key 1–5(含零值),不另存欄位
|
||||
- 依 sort 參數排序:helpful→`(helpful_count DESC, created_at DESC)`;rating→`(rating DESC, created_at DESC)`;newest→`(created_at DESC)`
|
||||
- 批次查詢 has_voted(已登入:`ReviewVote::whereIn('review_id', ...)->pluck('review_id')`;未登入:全 false)
|
||||
- is_mine:已登入才加此欄位(未登入省略)
|
||||
- reviewer_name 固定為「匿名潛水者」
|
||||
- [x] 5.2 [後端] 在 `routes/api.php` 新增 `GET /diving-offers/{id}/reviews` 公開路由
|
||||
|
||||
## 6. Admin 評價管理 API
|
||||
|
||||
- [x] 6.1 [後端] 建立 `app/Http/Controllers/API/AdminReviewController.php`:
|
||||
- `index`:全量列出(`created_at DESC`)含課程名、member email、rating、comment 前 50 字
|
||||
- `destroy`:DB transaction 刪除 Review(cascade)→ 呼叫 `recalculateOfferRating`(**Admin 刪除也必須重算**,與 Member destroy 共用同一邏輯)
|
||||
- [x] 6.2 [後端] 在 `routes/api.php` Admin 群組新增 `/admin/reviews` 路由(GET / DELETE /{id})
|
||||
|
||||
## 7. 前端 API 封裝
|
||||
|
||||
- [x] 7.1 [前端] 建立 `frontend/src/api/reviewApi.js`:`getReviews(offerId, sort)`、`createReview(payload)`、`updateReview(id, payload)`、`deleteReview(id)`、`toggleHelpful(reviewId)`
|
||||
|
||||
## 8. 課程詳情頁評價區塊
|
||||
|
||||
- [x] 8.1 [前端] 更新 `frontend/src/views/CourseDetailView.vue`:新增評價區塊,顯示整體星等、評分分布條、排序切換按鈕(最多幫助 / 最高分 / 最新)
|
||||
- [x] 8.2 [前端] 評價列表元件:顯示星等、「匿名潛水者」、日期、「已修改」標記、「有幫助 N 人」按鈕(登入後可點擊 Toggle)
|
||||
- [x] 8.3 [前端] 評價表單:已登入 Member 且有 completed booking 才顯示;已評過則顯示「我的評價」含修改/刪除按鈕
|
||||
|
||||
## 9. Admin 評價管理頁
|
||||
|
||||
- [x] 9.1 [前端] 新增 `frontend/src/views/admin/ReviewsView.vue`:列出所有評價(課程名、內容、星等、刪除按鈕)
|
||||
- [x] 9.2 [前端] 在 Admin Navbar 加入「評價管理」連結,路由 `/admin/reviews`
|
||||
- [x] 9.3 [前端] 在 `frontend/src/router/index.js` 新增 `/admin/reviews` 路由(requiresAdmin)
|
||||
|
||||
## 10. 整合驗證
|
||||
|
||||
- [x] 10.1 [整合測試] 完整流程:Member 完成課程 → 新增評價 → 確認 diving_offers.rating / reviews 更新
|
||||
- [x] 10.2 [整合測試] 修改評價:確認 is_edited=true、review_edits 有舊版、rating 重算正確
|
||||
- [x] 10.3 [整合測試] 刪除評價:rating/reviews 歸零或重算正確
|
||||
- [x] 10.4 [整合測試] 投票 Toggle:連點兩次確認 helpful_count 正確增減、第三次確認不低於 0
|
||||
- [x] 10.5 [整合測試] 不可投自己:Member 對自己評價投票應回傳 422
|
||||
- [x] 10.6 [整合測試] 匿名確認:API 回傳的 reviewer_name 一律為「匿名潛水者」,不含真實姓名
|
||||
- [x] 10.7 [整合測試] 排序確認:三種 sort 參數回傳順序正確
|
||||
- [x] 10.8 [整合測試] Admin 刪除重算:Admin 刪除評價後確認 diving_offers.rating / reviews 同步更新
|
||||
- [x] 10.9 [整合測試] is_mine / has_voted 欄位規則:未登入不含 is_mine 欄位;登入後自己的評價 is_mine=true;has_voted 正確反映投票狀態
|
||||
- [x] 10.10 [整合測試] 資格驗證:無 completed booking 的 Member 嘗試評價應回傳 403;有 completed booking 才能成功
|
||||
@@ -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