Files
a620906209 81a9f84b26 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>
2026-05-12 02:46:54 +08:00

291 lines
9.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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 不在 15 | 422 | rating 須為 15 的整數 |
| comment 為空 | 422 | 評論內容不可為空 |
**PUT /api/member/reviews/{id}(修改評價)**
| 情況 | HTTP | message |
|------|------|---------|
| 評價不存在 | 404 | 找不到此評價 |
| 非本人評價 | 403 | 無權修改此評價 |
| rating 不在 15 | 422 | rating 須為 15 的整數 |
| 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 補欄位。目前決策鎖定匿名,風險低