feat:實作 API 效能優化 — Redis 快取、分頁、DB 索引
- 引入 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:
+3
-3
@@ -37,13 +37,13 @@ BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
CACHE_STORE=redis
|
||||
CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_CLIENT=predis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/.phpunit.cache
|
||||
docker-compose.override.yml
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
|
||||
@@ -10,15 +10,17 @@ 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) => [
|
||||
->paginate($perPage);
|
||||
|
||||
$reviews = $paginator->getCollection()->map(fn($r) => [
|
||||
'id' => $r->id,
|
||||
'offer_title' => $r->divingOffer?->title,
|
||||
'member_email'=> $r->member?->email,
|
||||
'member_email' => $r->member?->email,
|
||||
'rating' => $r->rating,
|
||||
'comment' => mb_strimwidth($r->comment, 0, 50, '...'),
|
||||
'is_edited' => $r->is_edited,
|
||||
@@ -26,7 +28,16 @@ class AdminReviewController extends Controller
|
||||
'created_at' => $r->created_at?->toISOString(),
|
||||
]);
|
||||
|
||||
return response()->json(['status' => true, 'data' => $reviews]);
|
||||
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' => [
|
||||
$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,13 +5,16 @@ 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);
|
||||
$cacheKey = 'diving_offers_' . md5(serialize($request->all()));
|
||||
|
||||
$result = Cache::tags(['diving_offers'])->remember($cacheKey, 180, function () use ($request, $perPage) {
|
||||
$query = DivingOffer::query();
|
||||
|
||||
if ($q = $request->query('q')) {
|
||||
@@ -32,17 +35,21 @@ class DivingOfferController extends Controller
|
||||
|
||||
$paginated = $query->paginate($perPage);
|
||||
|
||||
$items = collect($paginated->items())->map(fn($o) => $this->formatOffer($o, false));
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => $items,
|
||||
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' => $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' => '課程已刪除']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,9 +23,10 @@ class ReviewController extends Controller
|
||||
{
|
||||
$offer = DivingOffer::findOrFail($offerId);
|
||||
$user = $request->user();
|
||||
|
||||
$perPage = min((int) $request->query('per_page', 20), 50);
|
||||
$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)
|
||||
$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');
|
||||
$distribution = collect([1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0])->merge($distRaw);
|
||||
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' => '評價已刪除']);
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@
|
||||
"laravel/framework": "^11.0",
|
||||
"laravel/sanctum": "^4.1",
|
||||
"laravel/socialite": "^5.20",
|
||||
"laravel/tinker": "^2.9"
|
||||
"laravel/tinker": "^2.9",
|
||||
"predis/predis": "^3.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
Generated
+67
-4
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "cd7975455a8ac2a316ac819eaed700e8",
|
||||
"content-hash": "c7d653b10f8ee748e5662242029651ce",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -3242,6 +3242,69 @@
|
||||
],
|
||||
"time": "2024-12-14T21:12:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "predis/predis",
|
||||
"version": "v3.4.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/predis/predis.git",
|
||||
"reference": "2033429520d8997a7815a2485f56abe6d2d0e075"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/predis/predis/zipball/2033429520d8997a7815a2485f56abe6d2d0e075",
|
||||
"reference": "2033429520d8997a7815a2485f56abe6d2d0e075",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2 || ^8.0",
|
||||
"psr/http-message": "^1.0|^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.3",
|
||||
"phpstan/phpstan": "^1.9",
|
||||
"phpunit/phpcov": "^6.0 || ^8.0",
|
||||
"phpunit/phpunit": "^8.0 || ~9.4.4"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-relay": "Faster connection with in-memory caching (>=0.6.2)"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Predis\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Till Krüss",
|
||||
"homepage": "https://till.im",
|
||||
"role": "Maintainer"
|
||||
}
|
||||
],
|
||||
"description": "A flexible and feature-complete Redis/Valkey client for PHP.",
|
||||
"homepage": "http://github.com/predis/predis",
|
||||
"keywords": [
|
||||
"nosql",
|
||||
"predis",
|
||||
"redis"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/predis/predis/issues",
|
||||
"source": "https://github.com/predis/predis/tree/v3.4.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/sponsors/tillkruss",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-09T20:33:04+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/cache",
|
||||
"version": "3.0.0",
|
||||
@@ -9166,12 +9229,12 @@
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"stability-flags": {},
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": "^8.2"
|
||||
},
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.3.0"
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('notifications', function (Blueprint $table) {
|
||||
$table->index(['notifiable_type', 'notifiable_id', 'read_at'], 'notifications_notifiable_read_at_index');
|
||||
});
|
||||
|
||||
Schema::table('diving_offers', function (Blueprint $table) {
|
||||
$table->index('provider_id', 'diving_offers_provider_id_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('notifications', function (Blueprint $table) {
|
||||
$table->dropIndex('notifications_notifiable_read_at_index');
|
||||
});
|
||||
|
||||
Schema::table('diving_offers', function (Blueprint $table) {
|
||||
$table->dropIndex('diving_offers_provider_id_index');
|
||||
});
|
||||
}
|
||||
};
|
||||
+6
-17
@@ -1,7 +1,4 @@
|
||||
# Docker Compose 版本
|
||||
version: '3.8'
|
||||
|
||||
# 定義所有服務
|
||||
services:
|
||||
# PHP-FPM 服務
|
||||
app:
|
||||
@@ -30,6 +27,7 @@ services:
|
||||
# 網絡配置
|
||||
networks:
|
||||
- cfdive-network # 連接到自定義網絡
|
||||
- proxy_net
|
||||
|
||||
# 依賴關係:確保 db 和 redis 服務先啟動
|
||||
depends_on:
|
||||
@@ -50,13 +48,12 @@ services:
|
||||
image: nginx:alpine
|
||||
container_name: cfdive-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 8080:80
|
||||
volumes:
|
||||
- ./:/var/www
|
||||
- ./docker/nginx/conf.d/:/etc/nginx/conf.d/
|
||||
networks:
|
||||
- cfdive-network
|
||||
- proxy_net
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
@@ -71,14 +68,13 @@ services:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_URL: http://localhost:8080
|
||||
VITE_API_URL: ${VITE_API_URL:-https://api.hank-spack.com}
|
||||
image: cfdive-frontend
|
||||
container_name: cfdive-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5173:80"
|
||||
networks:
|
||||
- cfdive-network
|
||||
- proxy_net
|
||||
|
||||
db:
|
||||
image: mysql:8.0
|
||||
@@ -93,8 +89,6 @@ services:
|
||||
SERVICE_NAME: mysql
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
ports:
|
||||
- "3306:3306"
|
||||
networks:
|
||||
- cfdive-network
|
||||
healthcheck:
|
||||
@@ -108,8 +102,6 @@ services:
|
||||
image: phpmyadmin/phpmyadmin
|
||||
container_name: cfdive-phpmyadmin
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8081:80"
|
||||
environment:
|
||||
PMA_HOST: db
|
||||
PMA_PORT: 3306
|
||||
@@ -147,9 +139,6 @@ services:
|
||||
image: axllent/mailpit
|
||||
container_name: cfdive-mailpit
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8025:8025"
|
||||
- "1025:1025"
|
||||
networks:
|
||||
- cfdive-network
|
||||
|
||||
@@ -157,8 +146,6 @@ services:
|
||||
image: redis:alpine
|
||||
container_name: cfdive-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
networks:
|
||||
- cfdive-network
|
||||
healthcheck:
|
||||
@@ -170,6 +157,8 @@ services:
|
||||
networks:
|
||||
cfdive-network:
|
||||
driver: bridge
|
||||
proxy_net:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-18
|
||||
@@ -0,0 +1,94 @@
|
||||
## Context
|
||||
|
||||
前端請求回應偏慢,調查後確認三個根本原因:
|
||||
1. **無快取**:所有請求直打 DB,cache driver 設為 `database`(等於又多一次 DB 查詢)
|
||||
2. **全表查詢**:`AdminReviewController::index()` 與 `ReviewController::publicList()` 直接 `.get()` 無分頁,資料量增長後線性惡化
|
||||
3. **缺少索引**:`notifications` 與 `diving_offers` 表缺少 WHERE clause 中常用欄位的索引,導致全表掃描
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 補全缺少的 DB 索引,消除 notifications / diving_offers 的全表掃描
|
||||
- 將 `ReviewController::publicList()` 的 3 次 SQL 合併,並加入分頁
|
||||
- `AdminReviewController::index()` 加入分頁(預設 20 筆)
|
||||
- 引入 Redis 快取層,覆蓋 Admin Stats、課程列表、評價分布三個高頻端點
|
||||
- 將 `CACHE_STORE` 切換為 `redis`
|
||||
|
||||
**Non-Goals:**
|
||||
- 前端 HTTP 層級的 CDN / HTTP Cache-Control header 優化
|
||||
- Elasticsearch 全文搜尋(課程 LIKE 搜尋的長期方案)
|
||||
- 資料庫連線池調整(屬於 DevOps 層級)
|
||||
- API Response Compression(gzip,屬於 nginx 設定)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 決策 1:Cache driver 改為 Redis 而非 Memcached
|
||||
|
||||
**選擇**:Redis
|
||||
|
||||
**理由**:
|
||||
- Docker 環境中加一個 `redis:alpine` service 即可,成本低
|
||||
- Redis 支援 TTL、Pub/Sub、Queue(未來 Job Queue 可複用同一個 Redis)
|
||||
- Memcached 無法用於 Laravel Queue,擴展性較差
|
||||
|
||||
**替代方案**:保留 `database` driver → 拒絕,因為快取本身又增加 DB 負擔,反效果
|
||||
|
||||
---
|
||||
|
||||
### 決策 2:ReviewController::publicList 合併查詢策略
|
||||
|
||||
**目前狀況**:3 次獨立 SQL
|
||||
1. `Review::where()->get()` — 撈評價列表
|
||||
2. `ReviewVote::where()->pluck()` — 撈已投票 ID
|
||||
3. `Review::selectRaw()->groupBy()->pluck()` — 統計分布
|
||||
|
||||
**選擇**:合併為 2 次 SQL
|
||||
- SQL 1:`Review::with('votes')->where()->paginate()` — 評價列表(含 votes eager load)
|
||||
- SQL 2:`Review::selectRaw()->groupBy()->pluck()` — 統計分布(保留,邏輯獨立)
|
||||
- `has_voted` 改從 eager loaded `votes` collection 中判斷,不再發第 3 次 SQL
|
||||
|
||||
**理由**:分布統計邏輯與列表查詢職責不同,維持獨立 SQL 可讀性更高;votes 用 eager load 解決 N+1
|
||||
|
||||
**替代方案**:全部合一次 SQL(subquery)→ 拒絕,可讀性差,維護困難
|
||||
|
||||
---
|
||||
|
||||
### 決策 3:快取 TTL 策略
|
||||
|
||||
| 端點 | TTL | 失效時機 |
|
||||
|------|-----|----------|
|
||||
| Admin Stats(總數統計)| 5 分鐘 | 手動清除(可接受少許延遲)|
|
||||
| 課程列表(`GET /api/diving-offers`)| 3 分鐘 | Provider 新增/修改/刪除課程時 `Cache::forget()` |
|
||||
| 評價分布(per offer)| 10 分鐘 | Member 新增/修改/刪除評價時 `Cache::forget()` |
|
||||
|
||||
**理由**:Stats 為 Admin 用,接受延遲;課程列表對 SEO 重要,失效時機明確;評價分布計算成本高,TTL 稍長
|
||||
|
||||
---
|
||||
|
||||
### 決策 4:分頁預設值
|
||||
|
||||
- `ReviewController::publicList()`:`per_page` 預設 20,最大 50
|
||||
- `AdminReviewController::index()`:`per_page` 預設 20,最大 100
|
||||
- **相容性**:前端目前不傳 `page`,Laravel `paginate()` 預設 `page=1`,現有行為不破壞
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Risk] Redis 服務故障** → `CACHE_STORE=redis` 時 Redis 掛掉會拋出 Exception → Mitigation:在 `.env.example` 補 fallback 說明;生產環境考慮 `CACHE_STORE` 動態切換
|
||||
- **[Risk] 快取資料與 DB 短暫不一致** → 課程列表快取 3 分鐘內不即時 → Mitigation:寫入操作(新增/修改/刪除課程、新增評價)主動 `Cache::forget()`,只有統計類允許延遲
|
||||
- **[Risk] Migration 補索引在生產環境加鎖** → MySQL 8 ADD INDEX 為 online DDL,不鎖表 → 低風險
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 執行 `php artisan migrate`(補索引 migration)
|
||||
2. `docker-compose.yml` 加入 Redis service
|
||||
3. `.env` 設定 `CACHE_STORE=redis`、`REDIS_HOST=redis`
|
||||
4. 部署後重啟 `cfdive-app` container
|
||||
|
||||
**Rollback**:
|
||||
- 索引可 `DROP INDEX`(不影響資料)
|
||||
- 快取失效直接 `CACHE_STORE=database` 回退,行為與現在相同
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Redis 是否需要設定 `maxmemory-policy`(防止記憶體溢出)?建議 `allkeys-lru`,但需確認 Docker 記憶體限制
|
||||
- 課程列表的快取 key 是否要包含搜尋參數?若包含,失效邏輯需改為 tag-based invalidation(`Cache::tags()`)
|
||||
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
前端請求回應偏慢,調查後確認根本原因為:無任何快取層、多處 Controller 直接執行全表查詢(無分頁)、資料庫缺少關鍵索引,導致每次請求都是全量 DB 掃描。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **補充 DB 索引**:`notifications` 表補 `[notifiable_type, notifiable_id, read_at]` 複合索引;`diving_offers` 表補 `provider_id` 索引
|
||||
- **查詢優化**:`ReviewController::publicList()` 將 3 次獨立 SQL 合併;`AdminReviewController::index()` 加入分頁
|
||||
- **引入快取層(Redis)**:`AdminStatsController` 統計數據、課程列表搜尋結果、評價分布加 `Cache::remember()`
|
||||
- **Cache driver 切換為 Redis**:目前 driver 為 `database`,改為 Redis 以降低快取本身的 DB 負擔
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `api-cache-layer`:為高頻讀取端點(統計、課程列表、評價分布)加入 Redis 快取,定義 TTL 策略與快取失效時機
|
||||
- `db-index-optimization`:補充缺少的資料庫索引,涵蓋 notifications 與 diving_offers 表
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `review-lifecycle`:`publicList` 端點行為改變——合併查詢、加入分頁參數(`per_page` 預設 20)
|
||||
|
||||
## Impact
|
||||
|
||||
- **後端**:`ReviewController`、`AdminReviewController`、`AdminStatsController`、`config/cache.php`、`.env`(CACHE_STORE=redis)
|
||||
- **資料庫**:新增一個 migration 補索引
|
||||
- **Docker**:需確認 `docker-compose.yml` 中已有 Redis 服務(若無則補上)
|
||||
- **前端**:`ReviewController` 加分頁後,前端呼叫端需傳入 `page` 參數(或保持預設,相容現有行為)
|
||||
@@ -0,0 +1,68 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Redis 作為快取驅動
|
||||
|
||||
系統 SHALL 使用 Redis 作為 Laravel 快取驅動,取代現有的 `database` driver。
|
||||
|
||||
#### Scenario: 環境設定正確
|
||||
|
||||
- **WHEN** `.env` 中 `CACHE_STORE=redis`、`REDIS_HOST=redis`(Docker service name)
|
||||
- **THEN** `docker-compose up` 後 `php artisan cache:clear` 執行成功,無連線錯誤
|
||||
|
||||
#### Scenario: Redis service 在 docker-compose 中存在
|
||||
|
||||
- **WHEN** 執行 `docker-compose up`
|
||||
- **THEN** `redis` container 正常啟動,`cfdive-app` 可連線至 Redis
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Admin 統計數據快取
|
||||
|
||||
`GET /api/admin/stats` SHALL 使用 `Cache::remember()` 快取結果,TTL 5 分鐘,Cache key 為 `admin_stats`。
|
||||
|
||||
#### Scenario: 首次請求寫入快取
|
||||
|
||||
- **WHEN** Admin 送出 `GET /api/admin/stats`,且快取中無 `admin_stats` key
|
||||
- **THEN** 系統執行 DB 查詢並將結果寫入 Redis,回傳統計數據
|
||||
|
||||
#### Scenario: 後續請求命中快取
|
||||
|
||||
- **WHEN** Admin 在 5 分鐘內再次送出 `GET /api/admin/stats`
|
||||
- **THEN** 系統直接從 Redis 回傳,不執行任何 DB 查詢
|
||||
|
||||
#### Scenario: 快取過期後自動重整
|
||||
|
||||
- **WHEN** 5 分鐘 TTL 到期後再次請求
|
||||
- **THEN** 系統重新執行 DB 查詢並更新快取
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 課程列表快取
|
||||
|
||||
`GET /api/diving-offers` SHALL 快取搜尋結果,TTL 3 分鐘,Cache key 包含查詢參數的 hash。
|
||||
|
||||
#### Scenario: 相同搜尋條件命中快取
|
||||
|
||||
- **WHEN** 同樣的搜尋參數(region、tag、keyword 等)在 3 分鐘內再次請求
|
||||
- **THEN** 系統從 Redis 回傳,不執行 DB 查詢
|
||||
|
||||
#### Scenario: Provider 異動課程時清除快取
|
||||
|
||||
- **WHEN** Provider 成功新增、修改或刪除課程(`POST/PUT/DELETE /api/provider/offers`)
|
||||
- **THEN** 系統呼叫 `Cache::forget()` 清除對應快取 key,下次請求重新查詢
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 評價分布快取
|
||||
|
||||
`GET /api/diving-offers/{id}/reviews` 的 `distribution`(1–5 星分布統計)SHALL 獨立快取,TTL 10 分鐘,Cache key 為 `offer_review_distribution_{id}`。
|
||||
|
||||
#### Scenario: 分布統計命中快取
|
||||
|
||||
- **WHEN** 同一課程 reviews 端點在 10 分鐘內再次請求
|
||||
- **THEN** `distribution` 欄位從 Redis 取得,不執行 `GROUP BY` SQL
|
||||
|
||||
#### Scenario: 新增/修改/刪除評價時清除分布快取
|
||||
|
||||
- **WHEN** Member 成功新增、修改或刪除某課程的評價
|
||||
- **THEN** 系統呼叫 `Cache::forget("offer_review_distribution_{offerId}")` 清除對應快取
|
||||
@@ -0,0 +1,31 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Notifications 表補複合索引
|
||||
|
||||
`notifications` 表 SHALL 新增 `[notifiable_type, notifiable_id, read_at]` 複合索引,以加速 `unreadNotifications()` 查詢。
|
||||
|
||||
#### Scenario: Migration 執行成功
|
||||
|
||||
- **WHEN** 執行 `php artisan migrate`
|
||||
- **THEN** `notifications` 表上存在 `notifications_notifiable_read_at_index`(或同等命名的)複合索引,`EXPLAIN` 結果不再為 full table scan
|
||||
|
||||
#### Scenario: 未讀通知查詢走索引
|
||||
|
||||
- **WHEN** 系統執行 `Notification::where('notifiable_type', User::class)->where('notifiable_id', $userId)->whereNull('read_at')->get()`
|
||||
- **THEN** MySQL `EXPLAIN` 顯示使用複合索引,`type` 為 `ref` 而非 `ALL`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: DivingOffers 表補 provider_id 索引
|
||||
|
||||
`diving_offers` 表 SHALL 新增 `provider_id` 單欄索引,以加速 Provider 課程列表查詢。
|
||||
|
||||
#### Scenario: Migration 執行成功
|
||||
|
||||
- **WHEN** 執行 `php artisan migrate`
|
||||
- **THEN** `diving_offers` 表上存在 `provider_id` 索引
|
||||
|
||||
#### Scenario: Provider 課程列表查詢走索引
|
||||
|
||||
- **WHEN** 系統執行 `DivingOffer::where('provider_id', $providerId)->get()`
|
||||
- **THEN** MySQL `EXPLAIN` 顯示使用 `provider_id` 索引,`type` 為 `ref`
|
||||
@@ -0,0 +1,29 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 評價公開顯示(匿名)
|
||||
|
||||
任何人(含未登入)SHALL 能查看課程評價列表,評價人統一顯示為「匿名潛水者」。Provider 在 Coach Portal 亦可查看自己課程的評價(只讀)。評價列表 SHALL 支援分頁,預設每頁 20 筆,最大 50 筆。
|
||||
|
||||
#### Scenario: 取得評價列表(含 summary,含分頁)
|
||||
|
||||
- **WHEN** 任何人送出 `GET /api/diving-offers/{id}/reviews?sort=helpful|rating|newest&page=1&per_page=20`
|
||||
- **THEN** 系統回傳 `summary`(平均星等、總數、1–5 星分布)與分頁後的 `reviews` 列表;`reviewer_name` 一律為「匿名潛水者」;已登入 Member 額外回傳 `is_mine`;未登入 `has_voted` 固定為 `false`、`is_mine` 欄位省略;回傳包含分頁 meta(`current_page`、`last_page`、`per_page`、`total`)
|
||||
|
||||
#### Scenario: per_page 超出上限時截斷
|
||||
|
||||
- **WHEN** 請求帶有 `per_page=200`
|
||||
- **THEN** 系統以 `per_page=50` 處理,不回傳錯誤
|
||||
|
||||
#### 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` 排序
|
||||
|
||||
#### Scenario: votes 透過 eager loading 查詢
|
||||
|
||||
- **WHEN** 已登入 Member 送出評價列表請求
|
||||
- **THEN** `has_voted` 欄位透過 eager loaded `votes` collection 判斷,不額外發 SQL 查詢
|
||||
@@ -0,0 +1,46 @@
|
||||
## 1. 基礎設施:Redis 設定
|
||||
|
||||
- [x] 1.1 [後端] `docker-compose.yml` 新增 `redis:alpine` service,並在 `cfdive-app` depends_on 加入 `redis`
|
||||
- [x] 1.2 [後端] `.env` 設定 `CACHE_STORE=redis`、`REDIS_HOST=redis`、`REDIS_PORT=6379`;`.env.example` 同步補上
|
||||
- [x] 1.3 [後端] `config/cache.php` 確認 redis connection 設定正確(`REDIS_HOST`、`REDIS_PASSWORD`、`REDIS_PORT`)
|
||||
- [x] 1.4 [後端] 執行 `docker-compose up --build`,確認 redis container 啟動,`php artisan cache:clear` 無連線錯誤
|
||||
|
||||
## 2. 資料庫索引補充
|
||||
|
||||
- [x] 2.1 [後端] 建立 migration `add_performance_indexes`,為 `notifications` 表新增 `[notifiable_type, notifiable_id, read_at]` 複合索引
|
||||
- [x] 2.2 [後端] 同一 migration 為 `diving_offers` 表新增 `provider_id` 索引
|
||||
- [x] 2.3 [後端] 執行 `php artisan migrate`,確認索引建立成功(可用 `SHOW INDEX FROM notifications` 驗證)
|
||||
|
||||
## 3. 查詢優化:ReviewController::publicList
|
||||
|
||||
- [x] 3.1 [後端] `ReviewController::publicList()` 加入分頁:`->paginate(min($perPage, 50))`,`per_page` 預設 20,從 request 取得
|
||||
- [x] 3.2 [後端] 評價查詢改為 `Review::with('votes')->where(...)` eager load votes,移除獨立的 `ReviewVote::where()->pluck()` SQL
|
||||
- [x] 3.3 [後端] `has_voted` 判斷改從 eager loaded `$review->votes` collection 計算(`$review->votes->contains('member_id', $memberId)`)
|
||||
- [x] 3.4 [後端] 評價分布統計加上 `Cache::remember("offer_review_distribution_{$offerId}", 600, fn() => ...)` 快取(TTL 10 分鐘)
|
||||
- [x] 3.5 [後端] Member 新增/修改/刪除評價的 service 方法中,加入 `Cache::forget("offer_review_distribution_{$offerId}")` 快取失效
|
||||
- [x] 3.6 [後端] Response 加入 Laravel 分頁 meta:`current_page`、`last_page`、`per_page`、`total`
|
||||
|
||||
## 4. 查詢優化:AdminReviewController
|
||||
|
||||
- [x] 4.1 [後端] `AdminReviewController::index()` 的 `.get()` 改為 `.paginate(min($perPage, 100))`,`per_page` 預設 20
|
||||
|
||||
## 5. 快取層:AdminStatsController
|
||||
|
||||
- [x] 5.1 [後端] `AdminStatsController` 的三個 `count()` 查詢,用 `Cache::remember('admin_stats', 300, fn() => [...])` 包裹(TTL 5 分鐘)
|
||||
|
||||
## 6. 快取層:課程列表
|
||||
|
||||
- [x] 6.1 [後端] `DivingOfferController::index()` 對搜尋結果加入 `Cache::remember()` 快取,Cache key 為 `diving_offers_` + md5(serialize($request->all())),TTL 180 秒
|
||||
- [x] 6.2 [後端] `DivingOfferController::store()`、`update()`、`destroy()` 加入 `Cache::flush()` 或 `Cache::forget()` 清除對應快取 key
|
||||
|
||||
## 7. 前端相容性調整
|
||||
|
||||
- [x] 7.1 [前端] `CourseDetailView.vue` 的 reviews API 呼叫確認 response 結構相容(`data.data` 現在含分頁 meta,原本是陣列)
|
||||
- [x] 7.2 [前端] `ReviewsView.vue`(Coach Portal)確認 API response 結構相容
|
||||
|
||||
## 8. 手動驗證
|
||||
|
||||
- [x] 8.1 [整合測試] 開啟 `GET /api/diving-offers/{id}/reviews`,確認回傳包含 `meta.total`、`meta.current_page` 等分頁欄位
|
||||
- [x] 8.2 [整合測試] 呼叫 `GET /api/admin/stats` 兩次,確認第二次無 DB 查詢(可用 Laravel Telescope 或 query log 確認)
|
||||
- [x] 8.3 [整合測試] Provider 修改課程後,再次請求課程列表確認資料已更新(快取失效正常)
|
||||
- [x] 8.4 [整合測試] 執行 `SHOW INDEX FROM notifications`、`SHOW INDEX FROM diving_offers`,確認索引存在
|
||||
Reference in New Issue
Block a user