diff --git a/app/Http/Controllers/API/AdminBookingController.php b/app/Http/Controllers/API/AdminBookingController.php
new file mode 100644
index 0000000..4a9df1f
--- /dev/null
+++ b/app/Http/Controllers/API/AdminBookingController.php
@@ -0,0 +1,44 @@
+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' => '預約已標記為完成']);
+ }
+}
diff --git a/app/Http/Controllers/API/AdminReviewController.php b/app/Http/Controllers/API/AdminReviewController.php
new file mode 100644
index 0000000..c963aaa
--- /dev/null
+++ b/app/Http/Controllers/API/AdminReviewController.php
@@ -0,0 +1,56 @@
+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,
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/API/ProviderBookingController.php b/app/Http/Controllers/API/ProviderBookingController.php
index 1adef81..7252543 100644
--- a/app/Http/Controllers/API/ProviderBookingController.php
+++ b/app/Http/Controllers/API/ProviderBookingController.php
@@ -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) {
diff --git a/app/Http/Controllers/API/ReviewController.php b/app/Http/Controllers/API/ReviewController.php
new file mode 100644
index 0000000..0ffa251
--- /dev/null
+++ b/app/Http/Controllers/API/ReviewController.php
@@ -0,0 +1,229 @@
+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(),
+ ];
+ }
+}
diff --git a/app/Http/Middleware/EnsureAdmin.php b/app/Http/Middleware/EnsureAdmin.php
new file mode 100644
index 0000000..8c38917
--- /dev/null
+++ b/app/Http/Middleware/EnsureAdmin.php
@@ -0,0 +1,19 @@
+user() || !$request->user()->isAdmin()) {
+ return response()->json(['status' => false, 'message' => '無權限存取'], 403);
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Models/DivingOffer.php b/app/Models/DivingOffer.php
index e10047f..edb0489 100644
--- a/app/Models/DivingOffer.php
+++ b/app/Models/DivingOffer.php
@@ -35,4 +35,9 @@ class DivingOffer extends Model
{
return $this->hasMany(CourseSchedule::class, 'diving_offer_id');
}
+
+ public function reviews()
+ {
+ return $this->hasMany(Review::class);
+ }
}
diff --git a/app/Models/Review.php b/app/Models/Review.php
new file mode 100644
index 0000000..24f82e5
--- /dev/null
+++ b/app/Models/Review.php
@@ -0,0 +1,43 @@
+ '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);
+ }
+}
diff --git a/app/Models/ReviewEdit.php b/app/Models/ReviewEdit.php
new file mode 100644
index 0000000..3a6f73c
--- /dev/null
+++ b/app/Models/ReviewEdit.php
@@ -0,0 +1,27 @@
+ 'integer',
+ 'edited_at' => 'datetime',
+ ];
+
+ public function review()
+ {
+ return $this->belongsTo(Review::class);
+ }
+}
diff --git a/app/Models/ReviewVote.php b/app/Models/ReviewVote.php
new file mode 100644
index 0000000..27300df
--- /dev/null
+++ b/app/Models/ReviewVote.php
@@ -0,0 +1,26 @@
+belongsTo(Review::class);
+ }
+
+ public function member()
+ {
+ return $this->belongsTo(User::class, 'member_id');
+ }
+}
diff --git a/bootstrap/app.php b/bootstrap/app.php
index d654276..a462725 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -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) {
//
diff --git a/database/migrations/2026_05_11_175856_create_reviews_table.php b/database/migrations/2026_05_11_175856_create_reviews_table.php
new file mode 100644
index 0000000..b6d683b
--- /dev/null
+++ b/database/migrations/2026_05_11_175856_create_reviews_table.php
@@ -0,0 +1,39 @@
+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');
+ }
+};
diff --git a/database/migrations/2026_05_11_175857_create_review_edits_table.php b/database/migrations/2026_05_11_175857_create_review_edits_table.php
new file mode 100644
index 0000000..7dd04bc
--- /dev/null
+++ b/database/migrations/2026_05_11_175857_create_review_edits_table.php
@@ -0,0 +1,30 @@
+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');
+ }
+};
diff --git a/database/migrations/2026_05_11_175858_create_review_votes_table.php b/database/migrations/2026_05_11_175858_create_review_votes_table.php
new file mode 100644
index 0000000..7db8722
--- /dev/null
+++ b/database/migrations/2026_05_11_175858_create_review_votes_table.php
@@ -0,0 +1,31 @@
+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');
+ }
+};
diff --git a/database/seeders/DevelopmentSeeder.php b/database/seeders/DevelopmentSeeder.php
new file mode 100644
index 0000000..56cd06c
--- /dev/null
+++ b/database/seeders/DevelopmentSeeder.php
@@ -0,0 +1,108 @@
+ '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');
+ }
+}
diff --git a/docker/nginx/conf.d/app.conf b/docker/nginx/conf.d/app.conf
index 7360631..c0c4690 100644
--- a/docker/nginx/conf.d/app.conf
+++ b/docker/nginx/conf.d/app.conf
@@ -1,7 +1,7 @@
# 定義一個 HTTP 服務器塊
server {
- # 監聽 80 端口(HTTP)
listen 80;
+ server_name cfdive.local localhost;
# 默認索引文件,按順序嘗試
index index.php index.html;
diff --git a/frontend/src/api/coachBookingApi.js b/frontend/src/api/coachBookingApi.js
index 39a2ab4..52a7747 100644
--- a/frontend/src/api/coachBookingApi.js
+++ b/frontend/src/api/coachBookingApi.js
@@ -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`)
+}
diff --git a/frontend/src/api/reviewApi.js b/frontend/src/api/reviewApi.js
new file mode 100644
index 0000000..c02899c
--- /dev/null
+++ b/frontend/src/api/reviewApi.js
@@ -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`)
+}
diff --git a/frontend/src/components/AdminNavBar.vue b/frontend/src/components/AdminNavBar.vue
index 4a4d6ff..fc8e145 100644
--- a/frontend/src/components/AdminNavBar.vue
+++ b/frontend/src/components/AdminNavBar.vue
@@ -20,6 +20,8 @@ async function handleLogout() {
會員管理
教練管理
課程管理
+ 預約管理
+ 評價管理
{{ adminAuth.user?.name }}
diff --git a/frontend/src/components/CoachNavBar.vue b/frontend/src/components/CoachNavBar.vue
index 1b866a4..5c9fef8 100644
--- a/frontend/src/components/CoachNavBar.vue
+++ b/frontend/src/components/CoachNavBar.vue
@@ -21,6 +21,7 @@ async function handleLogout() {
我的課程
時段管理
預約管理
+ 課程評價
個人資料
diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js
index 91f0b28..d3080c6 100644
--- a/frontend/src/router/index.js
+++ b/frontend/src/router/index.js
@@ -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') },
],
},
]
diff --git a/frontend/src/views/CourseDetailView.vue b/frontend/src/views/CourseDetailView.vue
index 836386d..0c0e3f0 100644
--- a/frontend/src/views/CourseDetailView.vue
+++ b/frontend/src/views/CourseDetailView.vue
@@ -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() {
+
+
+
+
+
+
+
課程評價
+
+ ★ {{ reviewSummary.average }} · {{ reviewSummary.total }} 則評價
+
+
+
+
+
+
+
+
+
+
+
{{ star }}★
+
+
{{ reviewSummary.distribution[star] }}
+
+
+
+
+
+
+
+
+
+
{{ editTarget ? '修改評價' : '撰寫評價' }}
+
+
+
+
+
+
{{ reviewForm.error }}
+
+
+
+
+
+
+
+
+
尚無評價
+
+
+
+
+
+ {{ '★'.repeat(r.rating) }}{{ '☆'.repeat(5 - r.rating) }}
+ {{ r.reviewer_name }}
+ (已修改)
+
+
{{ r.comment }}
+
{{ new Date(r.created_at).toLocaleDateString('zh-TW') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/admin/BookingsView.vue b/frontend/src/views/admin/BookingsView.vue
new file mode 100644
index 0000000..5c8a37c
--- /dev/null
+++ b/frontend/src/views/admin/BookingsView.vue
@@ -0,0 +1,85 @@
+
+
+
+
+
預約管理
+
+
載入中...
+
目前沒有預約
+
+
+
+
+
+ | 課程 |
+ 學員 |
+ 日期 |
+ 人數 |
+ 金額 |
+ 狀態 |
+ 操作 |
+
+
+
+
+ | {{ b.offer_title }} |
+
+ {{ b.member_name }}
+ {{ b.member_email }}
+ |
+ {{ b.scheduled_date }} {{ b.start_time }} |
+ {{ b.participants }} |
+ NT$ {{ b.total_price?.toLocaleString() }} |
+
+
+ {{ STATUS_LABEL[b.status]?.text || b.status }}
+
+ |
+
+
+ —
+ |
+
+
+
+
+
+
diff --git a/frontend/src/views/admin/ReviewsView.vue b/frontend/src/views/admin/ReviewsView.vue
new file mode 100644
index 0000000..e2adac8
--- /dev/null
+++ b/frontend/src/views/admin/ReviewsView.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
評價管理
+
+
載入中...
+
目前沒有評價
+
+
+
+
+
+ | 課程 |
+ 會員 |
+ 星等 |
+ 內容 |
+ 幫助 |
+ 操作 |
+
+
+
+
+ | {{ r.offer_title }} |
+ {{ r.member_email }} |
+
+ {{ '★'.repeat(r.rating) }}
+ (改)
+ |
+ {{ r.comment }} |
+ {{ r.helpful_count }} |
+
+
+ |
+
+
+
+
+
+
diff --git a/frontend/src/views/coach/BookingManagerView.vue b/frontend/src/views/coach/BookingManagerView.vue
index e70ded4..500bd8d 100644
--- a/frontend/src/views/coach/BookingManagerView.vue
+++ b/frontend/src/views/coach/BookingManagerView.vue
@@ -1,6 +1,6 @@
+
+
+
+
課程評價
+
學員對你課程的回饋(評價人已匿名)
+
+
載入中...
+
+
+ 目前沒有學員評價
+
+
+
+
+
+
+
+
+
{{ group.offer.title }}
+
+ ★ {{ group.summary.average }} · {{ group.summary.total }} 則評價
+
+
+
+
+
+
{{ star }}★
+
+
{{ group.summary.distribution[star] }}
+
+
+
+
+
+
+
+
+
+ 匿
+
+
+
+ {{ stars(r.rating) }}
+ {{ r.reviewer_name }}
+ (已修改)
+
+ {{ new Date(r.created_at).toLocaleDateString('zh-TW') }}
+
+
+
{{ r.comment }}
+
+ 👍 {{ r.helpful_count }} 人覺得有幫助
+
+
+
+
+
+
+
+
+
diff --git a/openspec/changes/archive/2026-05-12-review-system/.openspec.yaml b/openspec/changes/archive/2026-05-12-review-system/.openspec.yaml
new file mode 100644
index 0000000..81cd71f
--- /dev/null
+++ b/openspec/changes/archive/2026-05-12-review-system/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-05-11
diff --git a/openspec/changes/archive/2026-05-12-review-system/README.md b/openspec/changes/archive/2026-05-12-review-system/README.md
new file mode 100644
index 0000000..f57e0f2
--- /dev/null
+++ b/openspec/changes/archive/2026-05-12-review-system/README.md
@@ -0,0 +1,3 @@
+# review-system
+
+課程評價系統:完課後留評、匿名顯示、有幫助投票、三種排序
diff --git a/openspec/changes/archive/2026-05-12-review-system/design.md b/openspec/changes/archive/2026-05-12-review-system/design.md
new file mode 100644
index 0000000..aaa7c99
--- /dev/null
+++ b/openspec/changes/archive/2026-05-12-review-system/design.md
@@ -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 補欄位。目前決策鎖定匿名,風險低
diff --git a/openspec/changes/archive/2026-05-12-review-system/proposal.md b/openspec/changes/archive/2026-05-12-review-system/proposal.md
new file mode 100644
index 0000000..9311143
--- /dev/null
+++ b/openspec/changes/archive/2026-05-12-review-system/proposal.md
@@ -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` 欄位語意從假資料改為真實計算值
diff --git a/openspec/changes/archive/2026-05-12-review-system/specs/review-lifecycle/spec.md b/openspec/changes/archive/2026-05-12-review-system/specs/review-lifecycle/spec.md
new file mode 100644
index 0000000..f0f234d
--- /dev/null
+++ b/openspec/changes/archive/2026-05-12-review-system/specs/review-lifecycle/spec.md
@@ -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` 排序
diff --git a/openspec/changes/archive/2026-05-12-review-system/specs/review-voting/spec.md b/openspec/changes/archive/2026-05-12-review-system/specs/review-voting/spec.md
new file mode 100644
index 0000000..96fe654
--- /dev/null
+++ b/openspec/changes/archive/2026-05-12-review-system/specs/review-voting/spec.md
@@ -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`
diff --git a/openspec/changes/archive/2026-05-12-review-system/tasks.md b/openspec/changes/archive/2026-05-12-review-system/tasks.md
new file mode 100644
index 0000000..e61abcb
--- /dev/null
+++ b/openspec/changes/archive/2026-05-12-review-system/tasks.md
@@ -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 才能成功
diff --git a/openspec/specs/review-lifecycle/spec.md b/openspec/specs/review-lifecycle/spec.md
new file mode 100644
index 0000000..11ea212
--- /dev/null
+++ b/openspec/specs/review-lifecycle/spec.md
@@ -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`
diff --git a/openspec/specs/review-voting/spec.md b/openspec/specs/review-voting/spec.md
new file mode 100644
index 0000000..0878d69
--- /dev/null
+++ b/openspec/specs/review-voting/spec.md
@@ -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`
diff --git a/routes/api.php b/routes/api.php
index 89ea23d..e57f42a 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -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']);
});
// 需要認證的通用路由