chore:歸檔 api-performance-optimization,同步主規格

- 移至 openspec/changes/archive/2026-05-25-api-performance-optimization/
- 新增主規格:api-cache-layer、db-index-optimization
- 更新 review-lifecycle 規格(補分頁、eager load)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 00:45:08 +08:00
parent 0455c1577e
commit 849c33e83a
10 changed files with 107 additions and 4 deletions
@@ -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`,確認索引存在