diff --git a/.env.example b/.env.example index 82ddd36..a3239c3 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 8808326..33f4b39 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.phpunit.cache +docker-compose.override.yml /node_modules /public/build /public/hot diff --git a/app/Http/Controllers/API/AdminReviewController.php b/app/Http/Controllers/API/AdminReviewController.php index c963aaa..b781b89 100644 --- a/app/Http/Controllers/API/AdminReviewController.php +++ b/app/Http/Controllers/API/AdminReviewController.php @@ -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) diff --git a/app/Http/Controllers/API/AdminStatsController.php b/app/Http/Controllers/API/AdminStatsController.php index 10ea996..70259bb 100644 --- a/app/Http/Controllers/API/AdminStatsController.php +++ b/app/Http/Controllers/API/AdminStatsController.php @@ -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]); } } diff --git a/app/Http/Controllers/API/DivingOfferController.php b/app/Http/Controllers/API/DivingOfferController.php index 3a8b574..96d6e33 100644 --- a/app/Http/Controllers/API/DivingOfferController.php +++ b/app/Http/Controllers/API/DivingOfferController.php @@ -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'], ]); } diff --git a/app/Http/Controllers/API/ProviderOfferController.php b/app/Http/Controllers/API/ProviderOfferController.php index b086349..a7642fa 100644 --- a/app/Http/Controllers/API/ProviderOfferController.php +++ b/app/Http/Controllers/API/ProviderOfferController.php @@ -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' => '課程已刪除']); } } diff --git a/app/Http/Controllers/API/ReviewController.php b/app/Http/Controllers/API/ReviewController.php index e34ffc5..9c1da16 100644 --- a/app/Http/Controllers/API/ReviewController.php +++ b/app/Http/Controllers/API/ReviewController.php @@ -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' => '評價已刪除']); } diff --git a/composer.json b/composer.json index 7155ab3..7b0fe8c 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 24f57f9..cbc94d4 100644 --- a/composer.lock +++ b/composer.lock @@ -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" } diff --git a/database/migrations/2026_05_18_172221_add_performance_indexes.php b/database/migrations/2026_05_18_172221_add_performance_indexes.php new file mode 100644 index 0000000..5850454 --- /dev/null +++ b/database/migrations/2026_05_18_172221_add_performance_indexes.php @@ -0,0 +1,30 @@ +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'); + }); + } +}; diff --git a/docker-compose.yml b/docker-compose.yml index 2bafc9c..0c4f707 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/openspec/changes/api-performance-optimization/.openspec.yaml b/openspec/changes/api-performance-optimization/.openspec.yaml new file mode 100644 index 0000000..231e3ab --- /dev/null +++ b/openspec/changes/api-performance-optimization/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-18 diff --git a/openspec/changes/api-performance-optimization/design.md b/openspec/changes/api-performance-optimization/design.md new file mode 100644 index 0000000..2a001f9 --- /dev/null +++ b/openspec/changes/api-performance-optimization/design.md @@ -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()`) diff --git a/openspec/changes/api-performance-optimization/proposal.md b/openspec/changes/api-performance-optimization/proposal.md new file mode 100644 index 0000000..b85c386 --- /dev/null +++ b/openspec/changes/api-performance-optimization/proposal.md @@ -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` 參數(或保持預設,相容現有行為) diff --git a/openspec/changes/api-performance-optimization/specs/api-cache-layer/spec.md b/openspec/changes/api-performance-optimization/specs/api-cache-layer/spec.md new file mode 100644 index 0000000..d06f49d --- /dev/null +++ b/openspec/changes/api-performance-optimization/specs/api-cache-layer/spec.md @@ -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}")` 清除對應快取 diff --git a/openspec/changes/api-performance-optimization/specs/db-index-optimization/spec.md b/openspec/changes/api-performance-optimization/specs/db-index-optimization/spec.md new file mode 100644 index 0000000..350e538 --- /dev/null +++ b/openspec/changes/api-performance-optimization/specs/db-index-optimization/spec.md @@ -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` diff --git a/openspec/changes/api-performance-optimization/specs/review-lifecycle/spec.md b/openspec/changes/api-performance-optimization/specs/review-lifecycle/spec.md new file mode 100644 index 0000000..cfa7b65 --- /dev/null +++ b/openspec/changes/api-performance-optimization/specs/review-lifecycle/spec.md @@ -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 查詢 diff --git a/openspec/changes/api-performance-optimization/tasks.md b/openspec/changes/api-performance-optimization/tasks.md new file mode 100644 index 0000000..25a33dc --- /dev/null +++ b/openspec/changes/api-performance-optimization/tasks.md @@ -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`,確認索引存在