diff --git a/openspec/changes/api-performance-optimization/.openspec.yaml b/openspec/changes/archive/2026-05-25-api-performance-optimization/.openspec.yaml similarity index 100% rename from openspec/changes/api-performance-optimization/.openspec.yaml rename to openspec/changes/archive/2026-05-25-api-performance-optimization/.openspec.yaml diff --git a/openspec/changes/api-performance-optimization/design.md b/openspec/changes/archive/2026-05-25-api-performance-optimization/design.md similarity index 100% rename from openspec/changes/api-performance-optimization/design.md rename to openspec/changes/archive/2026-05-25-api-performance-optimization/design.md diff --git a/openspec/changes/api-performance-optimization/proposal.md b/openspec/changes/archive/2026-05-25-api-performance-optimization/proposal.md similarity index 100% rename from openspec/changes/api-performance-optimization/proposal.md rename to openspec/changes/archive/2026-05-25-api-performance-optimization/proposal.md diff --git a/openspec/changes/api-performance-optimization/specs/api-cache-layer/spec.md b/openspec/changes/archive/2026-05-25-api-performance-optimization/specs/api-cache-layer/spec.md similarity index 100% rename from openspec/changes/api-performance-optimization/specs/api-cache-layer/spec.md rename to openspec/changes/archive/2026-05-25-api-performance-optimization/specs/api-cache-layer/spec.md diff --git a/openspec/changes/api-performance-optimization/specs/db-index-optimization/spec.md b/openspec/changes/archive/2026-05-25-api-performance-optimization/specs/db-index-optimization/spec.md similarity index 100% rename from openspec/changes/api-performance-optimization/specs/db-index-optimization/spec.md rename to openspec/changes/archive/2026-05-25-api-performance-optimization/specs/db-index-optimization/spec.md diff --git a/openspec/changes/api-performance-optimization/specs/review-lifecycle/spec.md b/openspec/changes/archive/2026-05-25-api-performance-optimization/specs/review-lifecycle/spec.md similarity index 100% rename from openspec/changes/api-performance-optimization/specs/review-lifecycle/spec.md rename to openspec/changes/archive/2026-05-25-api-performance-optimization/specs/review-lifecycle/spec.md diff --git a/openspec/changes/api-performance-optimization/tasks.md b/openspec/changes/archive/2026-05-25-api-performance-optimization/tasks.md similarity index 100% rename from openspec/changes/api-performance-optimization/tasks.md rename to openspec/changes/archive/2026-05-25-api-performance-optimization/tasks.md diff --git a/openspec/specs/api-cache-layer/spec.md b/openspec/specs/api-cache-layer/spec.md new file mode 100644 index 0000000..51dadc9 --- /dev/null +++ b/openspec/specs/api-cache-layer/spec.md @@ -0,0 +1,66 @@ +### Requirement: Redis 作為快取驅動 + +系統 SHALL 使用 Redis 作為 Laravel 快取驅動,取代現有的 `database` driver。 + +#### Scenario: 環境設定正確 + +- **WHEN** `.env` 中 `CACHE_STORE=redis`、`REDIS_HOST=redis`(Docker service name)、`REDIS_CLIENT=predis` +- **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,並使用 `diving_offers` tag 管理失效。 + +#### Scenario: 相同搜尋條件命中快取 + +- **WHEN** 同樣的搜尋參數(region、tag、keyword 等)在 3 分鐘內再次請求 +- **THEN** 系統從 Redis 回傳,不執行 DB 查詢 + +#### Scenario: Provider 異動課程時清除快取 + +- **WHEN** Provider 成功新增、修改或刪除課程(`POST/PUT/DELETE /api/provider/offers`) +- **THEN** 系統呼叫 `Cache::tags(['diving_offers'])->flush()` 清除所有課程列表快取,下次請求重新查詢 + +--- + +### 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/specs/db-index-optimization/spec.md b/openspec/specs/db-index-optimization/spec.md new file mode 100644 index 0000000..01f72e9 --- /dev/null +++ b/openspec/specs/db-index-optimization/spec.md @@ -0,0 +1,29 @@ +### 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/specs/review-lifecycle/spec.md b/openspec/specs/review-lifecycle/spec.md index e5f23fc..a0d4e15 100644 --- a/openspec/specs/review-lifecycle/spec.md +++ b/openspec/specs/review-lifecycle/spec.md @@ -55,11 +55,15 @@ Member SHALL 能刪除自己的評價,Admin SHALL 能刪除任何評價。 - **THEN** 系統回傳 403 ### Requirement: 評價公開顯示(匿名) -任何人(含未登入)SHALL 能查看課程評價列表,評價人統一顯示為「匿名潛水者」。Provider 在 Coach Portal 亦可查看自己課程的評價(只讀)。 +任何人(含未登入)SHALL 能查看課程評價列表,評價人統一顯示為「匿名潛水者」。Provider 在 Coach Portal 亦可查看自己課程的評價(只讀)。評價列表 SHALL 支援分頁,預設每頁 20 筆,最大 50 筆。 -#### Scenario: 取得評價列表(含 summary) -- **WHEN** 任何人送出 `GET /api/diving-offers/{id}/reviews?sort=helpful|rating|newest` -- **THEN** 系統回傳 `summary`(平均星等、總數、1–5 星分布)與 `reviews` 列表;`reviewer_name` 一律為「匿名潛水者」;已登入 Member 額外回傳 `is_mine`;未登入 `has_voted` 固定為 `false`、`is_mine` 欄位省略 +#### 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`(預設) @@ -69,6 +73,10 @@ Member SHALL 能刪除自己的評價,Admin SHALL 能刪除任何評價。 - **WHEN** `sort=newest` - **THEN** 依 `created_at DESC` 排序 +#### Scenario: votes 透過 eager loading 查詢 +- **WHEN** 已登入 Member 送出評價列表請求 +- **THEN** `has_voted` 欄位透過 eager loaded `votes` collection 判斷,不額外發 SQL 查詢 + ### Requirement: 課程完成標記(評價資格觸發) Provider 或 Admin SHALL 能手動將 confirmed 預約標記為 completed,讓 Member 可立即評價,不需等待排程。