feat:實作 API 效能優化 — Redis 快取、分頁、DB 索引
Tests / PHP 8.2 (push) Failing after 1m35s
Tests / PHP 8.3 (push) Failing after 2s

- 引入 Redis(predis)快取層:Admin Stats(5分鐘)、課程列表(3分鐘,tag-based 失效)、評價分布(10分鐘)
- ReviewController::publicList 改為 paginate + eager load votes,消除 N+1
- AdminReviewController::index 加入分頁(預設 20,最大 100)
- 新增 notifications / diving_offers 效能索引 migration
- 新增 docker-compose.override.yml 本機開發 port mapping 機制(不進 git)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 00:17:16 +08:00
parent 6877ef30b5
commit 0455c1577e
18 changed files with 516 additions and 93 deletions
@@ -10,23 +10,34 @@ use Illuminate\Support\Facades\DB;
class AdminReviewController extends Controller
{
public function index()
public function index(Request $request)
{
$reviews = Review::with(['divingOffer', 'member'])
$perPage = min((int) $request->query('per_page', 20), 100);
$paginator = 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(),
]);
->paginate($perPage);
return response()->json(['status' => true, 'data' => $reviews]);
$reviews = $paginator->getCollection()->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,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
]);
}
public function destroy(int $id)
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\DivingOffer;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
class AdminStatsController extends Controller
{
@@ -14,13 +15,12 @@ class AdminStatsController extends Controller
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
}
return response()->json([
'status' => true,
'data' => [
'total_members' => User::where('role', 'member')->count(),
'total_providers' => User::where('role', 'provider')->count(),
'total_offers' => DivingOffer::count(),
],
$stats = Cache::remember('admin_stats', 300, fn() => [
'total_members' => User::where('role', 'member')->count(),
'total_providers' => User::where('role', 'provider')->count(),
'total_offers' => DivingOffer::count(),
]);
return response()->json(['status' => true, 'data' => $stats]);
}
}
@@ -5,44 +5,51 @@ namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\DivingOffer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class DivingOfferController extends Controller
{
public function index(Request $request)
{
$perPage = min((int) $request->query('per_page', 12), 50);
$perPage = min((int) $request->query('per_page', 12), 50);
$cacheKey = 'diving_offers_' . md5(serialize($request->all()));
$query = DivingOffer::query();
$result = Cache::tags(['diving_offers'])->remember($cacheKey, 180, function () use ($request, $perPage) {
$query = DivingOffer::query();
if ($q = $request->query('q')) {
$query->where(function ($sub) use ($q) {
$sub->where('title', 'like', "%{$q}%")
->orWhere('location', 'like', "%{$q}%")
->orWhere('spot', 'like', "%{$q}%");
});
}
if ($q = $request->query('q')) {
$query->where(function ($sub) use ($q) {
$sub->where('title', 'like', "%{$q}%")
->orWhere('location', 'like', "%{$q}%")
->orWhere('spot', 'like', "%{$q}%");
});
}
if ($region = $request->query('region')) {
$query->where('region', $region);
}
if ($region = $request->query('region')) {
$query->where('region', $region);
}
if ($tag = $request->query('tag')) {
$query->where('tag', 'like', "%{$tag}%");
}
if ($tag = $request->query('tag')) {
$query->where('tag', 'like', "%{$tag}%");
}
$paginated = $query->paginate($perPage);
$paginated = $query->paginate($perPage);
$items = collect($paginated->items())->map(fn($o) => $this->formatOffer($o, false));
return [
'items' => collect($paginated->items())->map(fn($o) => $this->formatOffer($o, false))->values(),
'meta' => [
'total' => $paginated->total(),
'per_page' => $paginated->perPage(),
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
],
];
});
return response()->json([
'status' => true,
'data' => $items,
'meta' => [
'total' => $paginated->total(),
'per_page' => $paginated->perPage(),
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
],
'data' => $result['items'],
'meta' => $result['meta'],
]);
}
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\DivingOffer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class ProviderOfferController extends Controller
{
@@ -60,6 +61,8 @@ class ProviderOfferController extends Controller
$offer = DivingOffer::create($validated);
Cache::tags(['diving_offers'])->flush();
return response()->json(['status' => true, 'data' => $offer], 201);
}
@@ -89,6 +92,8 @@ class ProviderOfferController extends Controller
$offer->fill($validated)->save();
Cache::tags(['diving_offers'])->flush();
return response()->json(['status' => true, 'data' => $offer]);
}
@@ -106,6 +111,8 @@ class ProviderOfferController extends Controller
$offer->delete();
Cache::tags(['diving_offers'])->flush();
return response()->json(['status' => true, 'message' => '課程已刪除']);
}
}
+39 -23
View File
@@ -11,6 +11,7 @@ use App\Models\ReviewEdit;
use App\Models\ReviewVote;
use App\Notifications\ReviewReceivedNotification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -20,11 +21,12 @@ class ReviewController extends Controller
public function publicList(Request $request, int $offerId)
{
$offer = DivingOffer::findOrFail($offerId);
$user = $request->user();
$offer = DivingOffer::findOrFail($offerId);
$user = $request->user();
$perPage = min((int) $request->query('per_page', 20), 50);
$sort = $request->query('sort', 'helpful');
$sort = $request->query('sort', 'helpful');
$query = Review::where('diving_offer_id', $offer->id);
$query = Review::with('votes')->where('diving_offer_id', $offer->id);
match ($sort) {
'rating' => $query->orderByDesc('rating')->orderByDesc('created_at'),
@@ -32,27 +34,25 @@ class ReviewController extends Controller
default => $query->orderByDesc('helpful_count')->orderByDesc('created_at'),
};
$reviews = $query->get();
$paginator = $query->paginate($perPage);
$reviews = $paginator->getCollection();
// 批次查詢 has_voted
$votedIds = $user
? ReviewVote::where('member_id', $user->id)
->whereIn('review_id', $reviews->pluck('id'))
->pluck('review_id')
->flip()
: collect();
$memberId = $user?->id;
// 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);
$distribution = Cache::remember("offer_review_distribution_{$offerId}", 600, function () use ($offerId) {
$distRaw = Review::where('diving_offer_id', $offerId)
->selectRaw('rating, COUNT(*) as cnt')
->groupBy('rating')
->pluck('cnt', 'rating');
return 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;
$total = $paginator->total();
$average = $total > 0 ? round(
Review::where('diving_offer_id', $offerId)->avg('rating'), 1
) : 0;
$formatted = $reviews->map(function ($r) use ($user, $votedIds) {
$formatted = $reviews->map(function ($r) use ($user, $memberId) {
$item = [
'id' => $r->id,
'reviewer_name' => '匿名潛水者',
@@ -61,10 +61,12 @@ class ReviewController extends Controller
'helpful_count' => $r->helpful_count,
'is_edited' => $r->is_edited,
'created_at' => $r->created_at?->toISOString(),
'has_voted' => $votedIds->has($r->id),
'has_voted' => $memberId
? $r->votes->contains('member_id', $memberId)
: false,
];
if ($user) {
$item['is_mine'] = $r->member_id === $user->id;
$item['is_mine'] = $r->member_id === $memberId;
}
return $item;
});
@@ -78,6 +80,12 @@ class ReviewController extends Controller
'distribution' => $distribution,
],
'reviews' => $formatted,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
],
]);
}
@@ -121,6 +129,8 @@ class ReviewController extends Controller
return $review;
});
Cache::forget("offer_review_distribution_{$offerId}");
try {
$offer = DivingOffer::with('provider')->findOrFail($offerId);
$provider = $offer->provider;
@@ -146,6 +156,8 @@ class ReviewController extends Controller
'comment' => 'sometimes|string|min:1',
]);
$offerId = $review->diving_offer_id;
DB::transaction(function () use ($review, $data) {
ReviewEdit::updateOrCreate(
['review_id' => $review->id],
@@ -155,6 +167,8 @@ class ReviewController extends Controller
$this->recalculateOfferRating($review->diving_offer_id);
});
Cache::forget("offer_review_distribution_{$offerId}");
return response()->json(['status' => true, 'message' => '評價已更新', 'data' => $this->formatReview($review->fresh())]);
}
@@ -171,6 +185,8 @@ class ReviewController extends Controller
$this->recalculateOfferRating($offerId);
});
Cache::forget("offer_review_distribution_{$offerId}");
return response()->json(['status' => true, 'message' => '評價已刪除']);
}