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:
2026-05-12 02:46:54 +08:00
parent 975b56ca54
commit 81a9f84b26
35 changed files with 1781 additions and 8 deletions
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\BookingStatus;
use App\Http\Controllers\Controller;
use App\Models\Booking;
class AdminBookingController extends Controller
{
public function index()
{
$bookings = Booking::with(['member', 'schedule.divingOffer'])
->orderByDesc('created_at')
->get()
->map(fn($b) => [
'id' => $b->id,
'member_name' => $b->member?->name,
'member_email' => $b->member?->email,
'offer_title' => $b->schedule?->divingOffer?->title,
'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(),
'start_time' => $b->schedule?->start_time,
'participants' => $b->participants,
'total_price' => $b->total_price,
'status' => $b->status->value,
'created_at' => $b->created_at?->toISOString(),
]);
return response()->json(['status' => true, 'data' => $bookings]);
}
public function complete(int $id)
{
$booking = Booking::findOrFail($id);
if (!$booking->canTransitionTo(BookingStatus::Completed)) {
return response()->json(['status' => false, 'message' => '只有已確認的預約才能標記完成'], 422);
}
$booking->update(['status' => BookingStatus::Completed]);
return response()->json(['status' => true, 'message' => '預約已標記為完成']);
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\DivingOffer;
use App\Models\Review;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AdminReviewController extends Controller
{
public function index()
{
$reviews = Review::with(['divingOffer', 'member'])
->orderByDesc('created_at')
->get()
->map(fn($r) => [
'id' => $r->id,
'offer_title' => $r->divingOffer?->title,
'member_email'=> $r->member?->email,
'rating' => $r->rating,
'comment' => mb_strimwidth($r->comment, 0, 50, '...'),
'is_edited' => $r->is_edited,
'helpful_count'=> $r->helpful_count,
'created_at' => $r->created_at?->toISOString(),
]);
return response()->json(['status' => true, 'data' => $reviews]);
}
public function destroy(int $id)
{
$review = Review::findOrFail($id);
$offerId = $review->diving_offer_id;
DB::transaction(function () use ($review, $offerId) {
$review->delete();
$this->recalculateOfferRating($offerId);
});
return response()->json(['status' => true, 'message' => '評價已刪除']);
}
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,
]);
}
}
@@ -94,6 +94,20 @@ class ProviderBookingController extends Controller
return response()->json(['status' => true, 'message' => '預約已取消']);
}
public function complete(Request $request, int $id)
{
$booking = Booking::with('schedule')->findOrFail($id);
$this->authorizeProvider($request, $booking);
if (!$booking->canTransitionTo(BookingStatus::Completed)) {
return response()->json(['status' => false, 'message' => '只有已確認的預約才能標記完成'], 422);
}
$booking->update(['status' => BookingStatus::Completed]);
return response()->json(['status' => true, 'message' => '預約已標記為完成']);
}
private function authorizeProvider(Request $request, Booking $booking): void
{
if ($booking->schedule->provider_id !== $request->user()->id) {
@@ -0,0 +1,229 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\BookingStatus;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Models\DivingOffer;
use App\Models\Review;
use App\Models\ReviewEdit;
use App\Models\ReviewVote;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ReviewController extends Controller
{
// ── 公開列表 ──────────────────────────────────────────────
public function publicList(Request $request, int $offerId)
{
$offer = DivingOffer::findOrFail($offerId);
$user = $request->user();
$sort = $request->query('sort', 'helpful');
$query = Review::where('diving_offer_id', $offer->id);
match ($sort) {
'rating' => $query->orderByDesc('rating')->orderByDesc('created_at'),
'newest' => $query->orderByDesc('created_at'),
default => $query->orderByDesc('helpful_count')->orderByDesc('created_at'),
};
$reviews = $query->get();
// 批次查詢 has_voted
$votedIds = $user
? ReviewVote::where('member_id', $user->id)
->whereIn('review_id', $reviews->pluck('id'))
->pluck('review_id')
->flip()
: collect();
// summary
$distRaw = Review::where('diving_offer_id', $offer->id)
->selectRaw('rating, COUNT(*) as cnt')
->groupBy('rating')
->pluck('cnt', 'rating');
$distribution = collect([1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0])->merge($distRaw);
$total = $reviews->count();
$average = $total > 0 ? round($reviews->avg('rating'), 1) : 0;
$formatted = $reviews->map(function ($r) use ($user, $votedIds) {
$item = [
'id' => $r->id,
'reviewer_name' => '匿名潛水者',
'rating' => $r->rating,
'comment' => $r->comment,
'helpful_count' => $r->helpful_count,
'is_edited' => $r->is_edited,
'created_at' => $r->created_at?->toISOString(),
'has_voted' => $votedIds->has($r->id),
];
if ($user) {
$item['is_mine'] = $r->member_id === $user->id;
}
return $item;
});
return response()->json([
'status' => true,
'data' => [
'summary' => [
'average' => $average,
'total' => $total,
'distribution' => $distribution,
],
'reviews' => $formatted,
],
]);
}
// ── Member CRUD ───────────────────────────────────────────
public function store(Request $request)
{
$data = $request->validate([
'diving_offer_id' => 'required|integer|exists:diving_offers,id',
'rating' => 'required|integer|min:1|max:5',
'comment' => 'required|string|min:1',
]);
$memberId = $request->user()->id;
$offerId = $data['diving_offer_id'];
// 資格驗證:有 completed booking
$eligible = Booking::where('member_id', $memberId)
->whereHas('schedule', fn($q) => $q->where('diving_offer_id', $offerId))
->where('status', BookingStatus::Completed->value)
->exists();
if (!$eligible) {
return response()->json(['status' => false, 'message' => '須完成此課程後才能評價'], 403);
}
// 重複評價檢查
if (Review::where('member_id', $memberId)->where('diving_offer_id', $offerId)->exists()) {
return response()->json(['status' => false, 'message' => '已評價,如需修改請使用編輯功能'], 422);
}
$review = DB::transaction(function () use ($data, $memberId, $offerId) {
$review = Review::create([
'diving_offer_id' => $offerId,
'member_id' => $memberId,
'rating' => $data['rating'],
'comment' => $data['comment'],
]);
$this->recalculateOfferRating($offerId);
return $review;
});
return response()->json(['status' => true, 'message' => '評價已送出', 'data' => $this->formatReview($review)], 201);
}
public function update(Request $request, int $id)
{
$review = Review::findOrFail($id);
if ($review->member_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權修改此評價'], 403);
}
$data = $request->validate([
'rating' => 'sometimes|integer|min:1|max:5',
'comment' => 'sometimes|string|min:1',
]);
DB::transaction(function () use ($review, $data) {
ReviewEdit::updateOrCreate(
['review_id' => $review->id],
['old_rating' => $review->rating, 'old_comment' => $review->comment, 'edited_at' => now()]
);
$review->update(array_merge($data, ['is_edited' => true]));
$this->recalculateOfferRating($review->diving_offer_id);
});
return response()->json(['status' => true, 'message' => '評價已更新', 'data' => $this->formatReview($review->fresh())]);
}
public function destroy(Request $request, int $id)
{
$review = Review::findOrFail($id);
if ($review->member_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權刪除此評價'], 403);
}
$offerId = $review->diving_offer_id;
DB::transaction(function () use ($review, $offerId) {
$review->delete();
$this->recalculateOfferRating($offerId);
});
return response()->json(['status' => true, 'message' => '評價已刪除']);
}
// ── 有幫助投票 ────────────────────────────────────────────
public function toggleHelpful(Request $request, int $id)
{
if (!$request->user()->isMember()) {
return response()->json(['status' => false, 'message' => '只有會員可以投票'], 403);
}
$review = Review::findOrFail($id);
$memberId = $request->user()->id;
if ($review->member_id === $memberId) {
return response()->json(['status' => false, 'message' => '不可對自己的評價投票'], 422);
}
DB::transaction(function () use ($review, $memberId) {
$vote = ReviewVote::where('review_id', $review->id)
->where('member_id', $memberId)
->first();
if ($vote) {
$vote->delete();
DB::table('reviews')
->where('id', $review->id)
->where('helpful_count', '>', 0)
->decrement('helpful_count');
} else {
ReviewVote::create(['review_id' => $review->id, 'member_id' => $memberId, 'created_at' => now()]);
$review->increment('helpful_count');
}
});
$review->refresh();
$hasVoted = ReviewVote::where('review_id', $review->id)->where('member_id', $memberId)->exists();
return response()->json(['status' => true, 'data' => ['helpful_count' => $review->helpful_count, 'has_voted' => $hasVoted]]);
}
// ── 私有方法 ──────────────────────────────────────────────
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,
]);
}
private function formatReview(Review $r): array
{
return [
'id' => $r->id,
'reviewer_name' => '匿名潛水者',
'rating' => $r->rating,
'comment' => $r->comment,
'helpful_count' => $r->helpful_count,
'is_edited' => $r->is_edited,
'created_at' => $r->created_at?->toISOString(),
];
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (!$request->user() || !$request->user()->isAdmin()) {
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
}
return $next($request);
}
}
+5
View File
@@ -35,4 +35,9 @@ class DivingOffer extends Model
{
return $this->hasMany(CourseSchedule::class, 'diving_offer_id');
}
public function reviews()
{
return $this->hasMany(Review::class);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Review extends Model
{
protected $fillable = [
'diving_offer_id',
'member_id',
'rating',
'comment',
'helpful_count',
'is_edited',
];
protected $casts = [
'rating' => 'integer',
'helpful_count' => 'integer',
'is_edited' => 'boolean',
];
public function divingOffer()
{
return $this->belongsTo(DivingOffer::class);
}
public function member()
{
return $this->belongsTo(User::class, 'member_id');
}
public function edit()
{
return $this->hasOne(ReviewEdit::class);
}
public function votes()
{
return $this->hasMany(ReviewVote::class);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ReviewEdit extends Model
{
public $timestamps = false;
protected $fillable = [
'review_id',
'old_rating',
'old_comment',
'edited_at',
];
protected $casts = [
'old_rating' => 'integer',
'edited_at' => 'datetime',
];
public function review()
{
return $this->belongsTo(Review::class);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ReviewVote extends Model
{
public $timestamps = false;
protected $fillable = [
'review_id',
'member_id',
'created_at',
];
public function review()
{
return $this->belongsTo(Review::class);
}
public function member()
{
return $this->belongsTo(User::class, 'member_id');
}
}
+3 -1
View File
@@ -12,7 +12,9 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
//
$middleware->alias([
'admin' => \App\Http\Middleware\EnsureAdmin::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
//
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('reviews', function (Blueprint $table) {
$table->id();
$table->foreignId('diving_offer_id')->constrained('diving_offers')->cascadeOnDelete();
$table->foreignId('member_id')->constrained('users')->cascadeOnDelete();
$table->tinyInteger('rating')->unsigned();
$table->text('comment');
$table->unsignedInteger('helpful_count')->default(0);
$table->boolean('is_edited')->default(false);
$table->timestamps();
$table->unique(['member_id', 'diving_offer_id']);
$table->index(['diving_offer_id', 'helpful_count'], 'idx_reviews_helpful');
$table->index(['diving_offer_id', 'rating'], 'idx_reviews_rating');
$table->index(['diving_offer_id', 'created_at'], 'idx_reviews_newest');
$table->index(['member_id', 'diving_offer_id'], 'idx_reviews_member_offer');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('reviews');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('review_edits', function (Blueprint $table) {
$table->id();
$table->foreignId('review_id')->unique()->constrained('reviews')->cascadeOnDelete();
$table->tinyInteger('old_rating')->unsigned();
$table->text('old_comment');
$table->timestamp('edited_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('review_edits');
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('review_votes', function (Blueprint $table) {
$table->id();
$table->foreignId('review_id')->constrained('reviews')->cascadeOnDelete();
$table->foreignId('member_id')->constrained('users')->cascadeOnDelete();
$table->timestamp('created_at')->useCurrent();
$table->unique(['review_id', 'member_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('review_votes');
}
};
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace Database\Seeders;
use App\Enums\BookingStatus;
use App\Enums\ScheduleStatus;
use App\Models\AdminProfile;
use App\Models\Booking;
use App\Models\CourseSchedule;
use App\Models\DivingOffer;
use App\Models\MemberProfile;
use App\Models\ProviderProfile;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DevelopmentSeeder extends Seeder
{
public function run(): void
{
// Admin
$admin = User::firstOrCreate(['email' => 'admin@cfdive.com'], [
'name' => '平台管理員', 'password' => Hash::make('password123'),
'role' => 'admin', 'is_active' => true,
]);
AdminProfile::firstOrCreate(['user_id' => $admin->id], ['department' => '營運']);
// Coach
$coach = User::firstOrCreate(['email' => 'coach@cfdive.com'], [
'name' => '蔡教練', 'password' => Hash::make('password123'),
'role' => 'provider', 'is_active' => true,
]);
ProviderProfile::firstOrCreate(['user_id' => $coach->id], [
'business_name' => '藍海潛水工作室',
'description' => '專業 PADI 認證教練,10 年教學經驗',
'contact_phone' => '0912345678',
'contact_email' => 'coach@cfdive.com',
'is_verified' => true,
]);
// Member
$member = User::firstOrCreate(['email' => 'member@cfdive.com'], [
'name' => '測試會員', 'password' => Hash::make('password123'),
'role' => 'member', 'is_active' => true,
]);
MemberProfile::firstOrCreate(['user_id' => $member->id], ['gender' => 'male']);
// Offers
$offer = DivingOffer::firstOrCreate(
['title' => '潛入海底 — 入門體驗', 'provider_id' => $coach->id],
[
'location' => '墾丁', 'spot' => '南灣', 'price' => 6000,
'region' => '南部', 'tag' => '初學者',
'badges' => ['PADI認證', '含裝備'],
'description' => '適合零基礎的水肺潛水入門課程,由專業教練全程陪同。',
'rating' => 0, 'reviews' => 0,
]
);
DivingOffer::firstOrCreate(
['title' => '進階深潛探索', 'provider_id' => $coach->id],
[
'location' => '小琉球', 'spot' => '美人洞', 'price' => 9800,
'region' => '南部', 'tag' => '進階',
'badges' => ['AOW認證', '含住宿'],
'description' => '探索 30 米深海,適合已有 OW 認證的潛水愛好者。',
'rating' => 0, 'reviews' => 0,
]
);
// 未來時段(開放預約)
$futureSchedule = CourseSchedule::firstOrCreate(
['diving_offer_id' => $offer->id, 'scheduled_date' => now()->addDays(14)->toDateString()],
[
'provider_id' => $coach->id, 'start_time' => '09:00',
'max_participants' => 5, 'current_participants' => 0,
'status' => ScheduleStatus::Open,
]
);
// 過去時段(供測試 completed booking
$pastSchedule = CourseSchedule::firstOrCreate(
['diving_offer_id' => $offer->id, 'scheduled_date' => now()->subDays(7)->toDateString()],
[
'provider_id' => $coach->id, 'start_time' => '09:00',
'max_participants' => 5, 'current_participants' => 1,
'status' => ScheduleStatus::Open,
]
);
// Pending booking(未來)
Booking::firstOrCreate(
['schedule_id' => $futureSchedule->id, 'member_id' => $member->id],
['participants' => 1, 'total_price' => $offer->price, 'status' => BookingStatus::Pending]
);
// Completed booking(可評價)
Booking::firstOrCreate(
['schedule_id' => $pastSchedule->id, 'member_id' => $member->id],
['participants' => 1, 'total_price' => $offer->price, 'status' => BookingStatus::Completed]
);
$this->command->info('✅ Seed 完成');
$this->command->info(' Admin: admin@cfdive.com / password123');
$this->command->info(' Coach: coach@cfdive.com / password123');
$this->command->info(' Member: member@cfdive.com / password123');
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
# 定義一個 HTTP 服務器塊
server {
# 監聽 80 端口(HTTP
listen 80;
server_name cfdive.local localhost;
# 默認索引文件,按順序嘗試
index index.php index.html;
+4
View File
@@ -15,3 +15,7 @@ export function rejectBooking(id) {
export function cancelBooking(id) {
return coachApi.put(`/provider/bookings/${id}/cancel`)
}
export function completeBooking(id) {
return coachApi.put(`/provider/bookings/${id}/complete`)
}
+27
View File
@@ -0,0 +1,27 @@
import api from './axios'
import axios from 'axios'
const publicApi = axios.create({
baseURL: import.meta.env.VITE_API_URL + '/api',
headers: { Accept: 'application/json' },
})
export function getReviews(offerId, sort = 'helpful') {
return publicApi.get(`/diving-offers/${offerId}/reviews`, { params: { sort } })
}
export function createReview(payload) {
return api.post('/member/reviews', payload)
}
export function updateReview(id, payload) {
return api.put(`/member/reviews/${id}`, payload)
}
export function deleteReview(id) {
return api.delete(`/member/reviews/${id}`)
}
export function toggleHelpful(reviewId) {
return api.post(`/reviews/${reviewId}/helpful`)
}
+2
View File
@@ -20,6 +20,8 @@ async function handleLogout() {
<RouterLink to="/admin/members" class="text-sm hover:text-slate-300 transition">會員管理</RouterLink>
<RouterLink to="/admin/providers" class="text-sm hover:text-slate-300 transition">教練管理</RouterLink>
<RouterLink to="/admin/offers" class="text-sm hover:text-slate-300 transition">課程管理</RouterLink>
<RouterLink to="/admin/bookings" class="text-sm hover:text-slate-300 transition">預約管理</RouterLink>
<RouterLink to="/admin/reviews" class="text-sm hover:text-slate-300 transition">評價管理</RouterLink>
</div>
<div class="flex items-center gap-4 text-sm">
<span class="text-slate-400">{{ adminAuth.user?.name }}</span>
+1
View File
@@ -21,6 +21,7 @@ async function handleLogout() {
<RouterLink to="/coach/dashboard" class="text-sm hover:text-gray-300 transition">我的課程</RouterLink>
<RouterLink to="/coach/schedules" class="text-sm hover:text-gray-300 transition">時段管理</RouterLink>
<RouterLink to="/coach/bookings" class="text-sm hover:text-gray-300 transition">預約管理</RouterLink>
<RouterLink to="/coach/reviews" class="text-sm hover:text-gray-300 transition">課程評價</RouterLink>
<RouterLink to="/coach/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink>
</div>
+3
View File
@@ -29,6 +29,7 @@ const routes = [
{ path: 'profile', component: () => import('../views/coach/ProfileView.vue') },
{ path: 'schedules', component: () => import('../views/coach/ScheduleManagerView.vue') },
{ path: 'bookings', component: () => import('../views/coach/BookingManagerView.vue') },
{ path: 'reviews', component: () => import('../views/coach/ReviewsView.vue') },
],
},
@@ -44,6 +45,8 @@ const routes = [
{ path: 'members', component: () => import('../views/admin/MembersView.vue') },
{ path: 'providers', component: () => import('../views/admin/ProvidersView.vue') },
{ path: 'offers', component: () => import('../views/admin/OffersView.vue') },
{ path: 'bookings', component: () => import('../views/admin/BookingsView.vue') },
{ path: 'reviews', component: () => import('../views/admin/ReviewsView.vue') },
],
},
]
+161 -1
View File
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import api from '../api/axios'
import { getSchedulesByOffer } from '../api/scheduleApi'
import { createBooking } from '../api/bookingApi'
import { getReviews, createReview, updateReview, deleteReview, toggleHelpful } from '../api/reviewApi'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
@@ -18,12 +19,24 @@ const selected = ref(null)
const participants = ref(1)
const booking = ref({ loading: false, success: false, error: '' })
// 評價相關
const reviewSort = ref('helpful')
const reviewSummary = ref(null)
const reviews = ref([])
const myReview = ref(null)
const reviewForm = ref({ show: false, rating: 5, comment: '', saving: false, error: '' })
const editTarget = ref(null)
onMounted(async () => {
try {
const res = await api.get(`/diving-offers/${route.params.id}`)
offer.value = res.data.data
const sRes = await getSchedulesByOffer(route.params.id)
const [sRes, rRes] = await Promise.all([
getSchedulesByOffer(route.params.id),
getReviews(route.params.id, reviewSort.value),
])
schedules.value = sRes.data.data
applyReviewData(rRes.data.data)
} catch (e) {
notFound.value = true
} finally {
@@ -31,6 +44,57 @@ onMounted(async () => {
}
})
function applyReviewData(data) {
reviewSummary.value = data.summary
reviews.value = data.reviews
myReview.value = data.reviews.find(r => r.is_mine) || null
}
async function switchSort(sort) {
reviewSort.value = sort
const res = await getReviews(route.params.id, sort)
applyReviewData(res.data.data)
}
async function submitReview() {
reviewForm.value.saving = true
reviewForm.value.error = ''
try {
if (editTarget.value) {
await updateReview(editTarget.value.id, { rating: reviewForm.value.rating, comment: reviewForm.value.comment })
} else {
await createReview({ diving_offer_id: offer.value.id, rating: reviewForm.value.rating, comment: reviewForm.value.comment })
}
reviewForm.value.show = false
editTarget.value = null
const res = await getReviews(route.params.id, reviewSort.value)
applyReviewData(res.data.data)
} catch (e) {
reviewForm.value.error = e.response?.data?.message || '送出失敗'
} finally {
reviewForm.value.saving = false
}
}
function openEdit(review) {
editTarget.value = review
reviewForm.value = { show: true, rating: review.rating, comment: review.comment, saving: false, error: '' }
}
async function doDeleteReview(review) {
if (!confirm('確定要刪除此評價?')) return
await deleteReview(review.id)
const res = await getReviews(route.params.id, reviewSort.value)
applyReviewData(res.data.data)
}
async function doToggleHelpful(review) {
if (!auth.isLoggedIn) return
const res = await toggleHelpful(review.id)
review.helpful_count = res.data.data.helpful_count
review.has_voted = res.data.data.has_voted
}
async function submitBooking() {
if (!selected.value) return
booking.value = { loading: true, success: false, error: '' }
@@ -154,6 +218,102 @@ async function submitBooking() {
</button>
</div>
</div>
<!-- 評價區塊 -->
<div class="bg-white rounded-2xl shadow p-6 mb-6">
<!-- 標題 + 排序 -->
<div class="flex items-center justify-between mb-5 flex-wrap gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-800">課程評價</h2>
<p v-if="reviewSummary" class="text-sm text-gray-500 mt-0.5">
{{ reviewSummary.average }} · {{ reviewSummary.total }} 則評價
</p>
</div>
<div class="flex gap-2 text-sm">
<button v-for="s in [['helpful','最多幫助'],['rating','最高分'],['newest','最新']]" :key="s[0]"
@click="switchSort(s[0])"
:class="reviewSort === s[0]
? 'bg-ocean-700 text-white px-3 py-1 rounded-full'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 px-3 py-1 rounded-full transition'">
{{ s[1] }}
</button>
</div>
</div>
<!-- 星等分布條 -->
<div v-if="reviewSummary?.total > 0" class="space-y-1 mb-6">
<div v-for="star in [5,4,3,2,1]" :key="star" class="flex items-center gap-2 text-sm">
<span class="w-8 text-right text-gray-500">{{ star }}</span>
<div class="flex-1 bg-gray-100 rounded-full h-2">
<div class="bg-yellow-400 h-2 rounded-full transition-all"
:style="`width:${reviewSummary.total > 0 ? (reviewSummary.distribution[star] / reviewSummary.total * 100) : 0}%`">
</div>
</div>
<span class="w-6 text-gray-400 text-xs">{{ reviewSummary.distribution[star] }}</span>
</div>
</div>
<!-- 我的評價 / 新增表單 -->
<div v-if="auth.isLoggedIn" class="mb-5">
<div v-if="!myReview && !reviewForm.show">
<button @click="reviewForm = { show: true, rating: 5, comment: '', saving: false, error: '' }; editTarget = null"
class="text-sm text-ocean-600 hover:underline">
+ 撰寫評價
</button>
</div>
<div v-if="reviewForm.show" class="border border-ocean-200 rounded-xl p-4 bg-ocean-50">
<p class="text-sm font-medium text-gray-700 mb-3">{{ editTarget ? '修改評價' : '撰寫評價' }}</p>
<!-- 星等選擇 -->
<div class="flex gap-1 mb-3">
<button v-for="n in [1,2,3,4,5]" :key="n" @click="reviewForm.rating = n"
:class="n <= reviewForm.rating ? 'text-yellow-400' : 'text-gray-300'"
class="text-2xl transition"></button>
</div>
<textarea v-model="reviewForm.comment" rows="3" placeholder="分享你的課程體驗..."
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ocean-400" />
<p v-if="reviewForm.error" class="text-red-500 text-xs mt-1">{{ reviewForm.error }}</p>
<div class="flex gap-2 mt-3">
<button @click="submitReview" :disabled="reviewForm.saving"
class="bg-ocean-700 text-white text-sm px-4 py-1.5 rounded-full hover:bg-ocean-600 transition disabled:opacity-60">
{{ reviewForm.saving ? '送出中...' : '送出' }}
</button>
<button @click="reviewForm.show = false; editTarget = null"
class="text-sm text-gray-500 hover:text-gray-700 px-4 py-1.5">取消</button>
</div>
</div>
</div>
<!-- 評價列表 -->
<div v-if="reviews.length === 0" class="text-gray-400 text-sm py-4 text-center">尚無評價</div>
<div v-else class="space-y-4">
<div v-for="r in reviews" :key="r.id"
class="pb-4 border-b border-gray-100 last:border-0">
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-2">
<span class="text-yellow-400 text-sm">{{ '★'.repeat(r.rating) }}{{ '☆'.repeat(5 - r.rating) }}</span>
<span class="text-xs text-gray-400">{{ r.reviewer_name }}</span>
<span v-if="r.is_edited" class="text-xs text-gray-400">已修改</span>
</div>
<p class="text-sm text-gray-700 mt-1 leading-relaxed">{{ r.comment }}</p>
<p class="text-xs text-gray-400 mt-1">{{ new Date(r.created_at).toLocaleDateString('zh-TW') }}</p>
</div>
<!-- 本人操作 -->
<div v-if="r.is_mine" class="flex gap-2 text-xs ml-3 shrink-0">
<button @click="openEdit(r)" class="text-ocean-600 hover:underline">修改</button>
<button @click="doDeleteReview(r)" class="text-red-400 hover:underline">刪除</button>
</div>
</div>
<!-- 有幫助 -->
<button @click="doToggleHelpful(r)"
:class="r.has_voted ? 'text-ocean-600' : 'text-gray-400 hover:text-gray-600'"
class="mt-2 text-xs flex items-center gap-1 transition"
:disabled="!auth.isLoggedIn">
👍 有幫助 {{ r.helpful_count > 0 ? `(${r.helpful_count})` : '' }}
</button>
</div>
</div>
</div>
</template>
</main>
+85
View File
@@ -0,0 +1,85 @@
<script setup>
import { ref, onMounted } from 'vue'
import adminApi from '../../api/adminAxios'
const bookings = ref([])
const loading = ref(true)
const STATUS_LABEL = {
pending: { text: '待確認', color: 'bg-yellow-100 text-yellow-700' },
confirmed: { text: '已確認', color: 'bg-green-100 text-green-700' },
completed: { text: '已完成', color: 'bg-gray-100 text-gray-600' },
rejected: { text: '已拒絕', color: 'bg-red-100 text-red-600' },
expired: { text: '已過期', color: 'bg-gray-100 text-gray-400' },
member_cancelled: { text: '學員取消', color: 'bg-gray-100 text-gray-500' },
provider_cancelled: { text: '教練取消', color: 'bg-orange-100 text-orange-600' },
}
onMounted(async () => {
try {
const res = await adminApi.get('/admin/bookings')
bookings.value = res.data.data
} finally {
loading.value = false
}
})
async function doComplete(booking) {
if (!confirm(`確定要將「${booking.member_name}」的預約標記為完成?`)) return
try {
await adminApi.put(`/admin/bookings/${booking.id}/complete`)
booking.status = 'completed'
} catch (e) {
alert(e.response?.data?.message || '操作失敗')
}
}
</script>
<template>
<div class="p-6 max-w-6xl mx-auto">
<h1 class="text-2xl font-bold text-gray-800 mb-6">預約管理</h1>
<div v-if="loading" class="text-center text-gray-400 py-20">載入中...</div>
<div v-else-if="bookings.length === 0" class="text-center text-gray-400 py-20">目前沒有預約</div>
<div v-else class="bg-white rounded-2xl shadow overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500 text-xs uppercase tracking-wide">
<tr>
<th class="px-5 py-3 text-left">課程</th>
<th class="px-5 py-3 text-left">學員</th>
<th class="px-5 py-3 text-left">日期</th>
<th class="px-5 py-3 text-center">人數</th>
<th class="px-5 py-3 text-right">金額</th>
<th class="px-5 py-3 text-center">狀態</th>
<th class="px-5 py-3 text-center">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr v-for="b in bookings" :key="b.id" class="hover:bg-gray-50">
<td class="px-5 py-3 font-medium text-gray-800 max-w-[140px] truncate">{{ b.offer_title }}</td>
<td class="px-5 py-3 text-gray-500 text-xs">
<p>{{ b.member_name }}</p>
<p class="text-gray-400">{{ b.member_email }}</p>
</td>
<td class="px-5 py-3 text-gray-500 text-xs">{{ b.scheduled_date }} {{ b.start_time }}</td>
<td class="px-5 py-3 text-center text-gray-600">{{ b.participants }}</td>
<td class="px-5 py-3 text-right text-gray-700">NT$ {{ b.total_price?.toLocaleString() }}</td>
<td class="px-5 py-3 text-center">
<span class="text-xs px-2 py-1 rounded-full font-medium" :class="STATUS_LABEL[b.status]?.color">
{{ STATUS_LABEL[b.status]?.text || b.status }}
</span>
</td>
<td class="px-5 py-3 text-center">
<button v-if="b.status === 'confirmed'" @click="doComplete(b)"
class="text-xs bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded-full transition">
標記完成
</button>
<span v-else class="text-gray-300 text-xs"></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
+67
View File
@@ -0,0 +1,67 @@
<script setup>
import { ref, onMounted } from 'vue'
import adminApi from '../../api/adminAxios'
const reviews = ref([])
const loading = ref(true)
onMounted(fetchReviews)
async function fetchReviews() {
loading.value = true
try {
const res = await adminApi.get('/admin/reviews')
reviews.value = res.data.data
} finally {
loading.value = false
}
}
async function doDelete(review) {
if (!confirm(`確定要刪除「${review.offer_title}」的這則評價?`)) return
await adminApi.delete(`/admin/reviews/${review.id}`)
reviews.value = reviews.value.filter(r => r.id !== review.id)
}
</script>
<template>
<div class="p-6 max-w-5xl mx-auto">
<h1 class="text-2xl font-bold text-gray-800 mb-6">評價管理</h1>
<div v-if="loading" class="text-center text-gray-400 py-20">載入中...</div>
<div v-else-if="reviews.length === 0" class="text-center text-gray-400 py-20">目前沒有評價</div>
<div v-else class="bg-white rounded-2xl shadow overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500 text-xs uppercase tracking-wide">
<tr>
<th class="px-5 py-3 text-left">課程</th>
<th class="px-5 py-3 text-left">會員</th>
<th class="px-5 py-3 text-center">星等</th>
<th class="px-5 py-3 text-left">內容</th>
<th class="px-5 py-3 text-center">幫助</th>
<th class="px-5 py-3 text-center">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr v-for="r in reviews" :key="r.id" class="hover:bg-gray-50">
<td class="px-5 py-3 font-medium text-gray-800 max-w-[140px] truncate">{{ r.offer_title }}</td>
<td class="px-5 py-3 text-gray-500 text-xs">{{ r.member_email }}</td>
<td class="px-5 py-3 text-center">
<span class="text-yellow-400">{{ '★'.repeat(r.rating) }}</span>
<span v-if="r.is_edited" class="text-gray-400 text-xs ml-1"></span>
</td>
<td class="px-5 py-3 text-gray-600 max-w-[240px] truncate">{{ r.comment }}</td>
<td class="px-5 py-3 text-center text-gray-400 text-xs">{{ r.helpful_count }}</td>
<td class="px-5 py-3 text-center">
<button @click="doDelete(r)"
class="text-xs bg-red-50 hover:bg-red-100 text-red-600 px-3 py-1 rounded-lg transition">
刪除
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
@@ -1,6 +1,6 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { getProviderBookings, confirmBooking, rejectBooking, cancelBooking } from '../../api/coachBookingApi'
import { getProviderBookings, confirmBooking, rejectBooking, cancelBooking, completeBooking } from '../../api/coachBookingApi'
const bookings = ref([])
const loading = ref(true)
@@ -48,9 +48,10 @@ async function doAction(booking, action) {
const labels = { confirm: '確認', reject: '拒絕', cancel: '取消' }
if (!confirm(`確定要${labels[action]}此預約?`)) return
try {
if (action === 'confirm') await confirmBooking(booking.id)
if (action === 'reject') await rejectBooking(booking.id)
if (action === 'cancel') await cancelBooking(booking.id)
if (action === 'confirm') await confirmBooking(booking.id)
if (action === 'reject') await rejectBooking(booking.id)
if (action === 'cancel') await cancelBooking(booking.id)
if (action === 'complete') await completeBooking(booking.id)
await fetchBookings()
} catch (e) {
alert(e.response?.data?.message || '操作失敗')
@@ -115,6 +116,10 @@ async function doAction(booking, action) {
class="text-xs bg-red-500 hover:bg-red-400 text-white px-3 py-1 rounded-full transition">
拒絕
</button>
<button v-if="b.status === 'confirmed'" @click="doAction(b, 'complete')"
class="text-xs bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded-full transition">
完成
</button>
<button v-if="b.status === 'confirmed'" @click="doAction(b, 'cancel')"
class="text-xs text-orange-500 hover:text-orange-700 underline">
取消
+101
View File
@@ -0,0 +1,101 @@
<script setup>
import { ref, onMounted } from 'vue'
import coachApi from '../../api/coachAxios'
import axios from 'axios'
const publicApi = axios.create({
baseURL: import.meta.env.VITE_API_URL + '/api',
headers: { Accept: 'application/json' },
})
const offers = ref([])
const reviews = ref([]) // [{ offer, reviews, summary }]
const loading = ref(true)
onMounted(async () => {
try {
const offersRes = await coachApi.get('/provider/offers')
offers.value = offersRes.data.data
const results = await Promise.all(
offers.value.map(async (offer) => {
const res = await publicApi.get(`/diving-offers/${offer.id}/reviews`)
return { offer, ...res.data.data }
})
)
// 只顯示有評價的課程
reviews.value = results.filter(r => r.summary.total > 0)
} finally {
loading.value = false
}
})
function stars(n) {
return '★'.repeat(n) + '☆'.repeat(5 - n)
}
</script>
<template>
<div class="p-6 max-w-4xl mx-auto">
<h1 class="text-2xl font-bold text-gray-800 mb-2">課程評價</h1>
<p class="text-sm text-gray-500 mb-6">學員對你課程的回饋評價人已匿名</p>
<div v-if="loading" class="text-center text-gray-400 py-20">載入中...</div>
<div v-else-if="reviews.length === 0" class="text-center text-gray-400 py-20">
目前沒有學員評價
</div>
<div v-else class="space-y-8">
<div v-for="group in reviews" :key="group.offer.id" class="bg-white rounded-2xl shadow p-6">
<!-- 課程標題與統計 -->
<div class="flex items-start justify-between mb-4 flex-wrap gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-800">{{ group.offer.title }}</h2>
<p class="text-sm text-gray-500 mt-0.5">
{{ group.summary.average }} · {{ group.summary.total }} 則評價
</p>
</div>
<!-- 評分分布 -->
<div class="space-y-0.5 min-w-[160px]">
<div v-for="star in [5,4,3,2,1]" :key="star" class="flex items-center gap-1.5 text-xs">
<span class="text-gray-400 w-4">{{ star }}</span>
<div class="flex-1 bg-gray-100 rounded-full h-1.5">
<div class="bg-yellow-400 h-1.5 rounded-full"
:style="`width:${group.summary.total > 0 ? (group.summary.distribution[star] / group.summary.total * 100) : 0}%`">
</div>
</div>
<span class="text-gray-400 w-3 text-right">{{ group.summary.distribution[star] }}</span>
</div>
</div>
</div>
<!-- 評價列表 -->
<div class="divide-y divide-gray-100">
<div v-for="r in group.reviews" :key="r.id" class="py-4 first:pt-0">
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-full bg-ocean-100 flex items-center justify-center text-ocean-600 text-sm font-bold shrink-0">
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-yellow-400 text-sm">{{ stars(r.rating) }}</span>
<span class="text-xs text-gray-400">{{ r.reviewer_name }}</span>
<span v-if="r.is_edited" class="text-xs text-gray-400">已修改</span>
<span class="text-xs text-gray-400 ml-auto">
{{ new Date(r.created_at).toLocaleDateString('zh-TW') }}
</span>
</div>
<p class="text-sm text-gray-700 leading-relaxed">{{ r.comment }}</p>
<p class="text-xs text-gray-400 mt-1.5">
👍 {{ r.helpful_count }} 人覺得有幫助
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -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 不在 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 補欄位。目前決策鎖定匿名,風險低
@@ -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`15 整數)、`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` 不在 15 之間
- **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`(平均星等、總數、15 星分布)與 `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 transactionupdateOrCreate review_edits(覆蓋舊版)→ 更新 Review 內容 + is_edited=true → 呼叫 recalculate
- `destroy`:所有權驗證(他人 403)→ DB transaction:刪除 Reviewcascade 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 刪除 Reviewcascade)→ 呼叫 `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=truehas_voted 正確反映投票狀態
- [x] 10.10 [整合測試] 資格驗證:無 completed booking 的 Member 嘗試評價應回傳 403;有 completed booking 才能成功
+81
View File
@@ -0,0 +1,81 @@
### Requirement: Member 新增評價
已完成特定課程的 Member SHALL 能對該課程留下一次評價(星等 + 文字)。
#### Scenario: 成功新增評價
- **WHEN** 已登入 Member 送出 `POST /api/member/reviews`,包含 `diving_offer_id``rating`15 整數)、`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` 不在 15 之間
- **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`(平均星等、總數、15 星分布)與 `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`
+33
View File
@@ -0,0 +1,33 @@
### Requirement: Member 對評價投「有幫助」票
已登入 **Member**role = memberSHALL 能對評價投「有幫助」票,可取消,不可重複投票。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`
+19 -1
View File
@@ -7,6 +7,9 @@ use App\Http\Controllers\API\ProviderOfferController;
use App\Http\Controllers\API\ScheduleController;
use App\Http\Controllers\API\ProviderBookingController;
use App\Http\Controllers\API\MemberBookingController;
use App\Http\Controllers\API\ReviewController;
use App\Http\Controllers\API\AdminReviewController;
use App\Http\Controllers\API\AdminBookingController;
use App\Http\Controllers\API\AdminStatsController;
use App\Http\Controllers\API\AdminUserController;
use App\Http\Controllers\API\AdminOfferController;
@@ -20,6 +23,7 @@ Route::get('/ping', function () {
Route::get('/diving-offers', [DivingOfferController::class, 'index']);
Route::get('/diving-offers/{id}', [DivingOfferController::class, 'show']);
Route::get('/diving-offers/{id}/schedules', [ScheduleController::class, 'publicList']);
Route::get('/diving-offers/{id}/reviews', [ReviewController::class, 'publicList']);
// 你可以在這裡繼續新增 API 路由
Route::post('/testpost', function () {
@@ -52,8 +56,15 @@ Route::middleware(['auth:sanctum'])->prefix('member')->group(function () {
Route::post('/bookings', [MemberBookingController::class, 'store']);
Route::get('/bookings/{id}', [MemberBookingController::class, 'show']);
Route::delete('/bookings/{id}', [MemberBookingController::class, 'destroy']);
// 評價
Route::post('/reviews', [ReviewController::class, 'store']);
Route::put('/reviews/{id}', [ReviewController::class, 'update']);
Route::delete('/reviews/{id}',[ReviewController::class, 'destroy']);
});
// 有幫助投票(需登入,但不限 member prefix
Route::middleware('auth:sanctum')->post('/reviews/{id}/helpful', [ReviewController::class, 'toggleHelpful']);
// 服務提供者註冊/登入
Route::post('/provider/register', [AuthController::class, 'registerProvider']);
Route::post('/provider/login', [AuthController::class, 'loginProvider']);
@@ -84,6 +95,7 @@ Route::middleware(['auth:sanctum'])->prefix('provider')->group(function () {
Route::put('/bookings/{id}/confirm', [ProviderBookingController::class, 'confirm']);
Route::put('/bookings/{id}/reject', [ProviderBookingController::class, 'reject']);
Route::put('/bookings/{id}/cancel', [ProviderBookingController::class, 'cancel']);
Route::put('/bookings/{id}/complete', [ProviderBookingController::class, 'complete']);
});
// 管理員註冊/登入
@@ -91,7 +103,7 @@ Route::post('/admin/register', [AuthController::class, 'registerAdmin']);
Route::post('/admin/login', [AuthController::class, 'loginAdmin']);
// 管理員專屬 API(需登入)
Route::middleware(['auth:sanctum'])->prefix('admin')->group(function () {
Route::middleware(['auth:sanctum', 'admin'])->prefix('admin')->group(function () {
// 管理員登出
Route::post('/logout', [AuthController::class, 'logoutAdmin']);
// 取得管理員個人資料
@@ -117,6 +129,12 @@ Route::middleware(['auth:sanctum'])->prefix('admin')->group(function () {
// 課程管理
Route::get('/offers', [AdminOfferController::class, 'index']);
Route::delete('/offers/{id}', [AdminOfferController::class, 'destroy']);
// 預約管理
Route::get('/bookings', [AdminBookingController::class, 'index']);
Route::put('/bookings/{id}/complete', [AdminBookingController::class, 'complete']);
// 評價管理
Route::get('/reviews', [AdminReviewController::class, 'index']);
Route::delete('/reviews/{id}', [AdminReviewController::class, 'destroy']);
});
// 需要認證的通用路由