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
+3 -3
View File
@@ -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
View File
@@ -1,4 +1,5 @@
/.phpunit.cache
docker-compose.override.yml
/node_modules
/public/build
/public/hot
@@ -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' => '評價已刪除']);
}
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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. **無快取**:所有請求直打 DBcache 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 Compressiongzip,屬於 nginx 設定)
## Decisions
### 決策 1Cache driver 改為 Redis 而非 Memcached
**選擇**Redis
**理由**
- Docker 環境中加一個 `redis:alpine` service 即可,成本低
- Redis 支援 TTL、Pub/Sub、Queue(未來 Job Queue 可複用同一個 Redis
- Memcached 無法用於 Laravel Queue,擴展性較差
**替代方案**:保留 `database` driver → 拒絕,因為快取本身又增加 DB 負擔,反效果
---
### 決策 2ReviewController::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`,確認索引存在