From aeb8c975e22877ca279cd99a2f9cd81c97ed028c Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 11 Jun 2026 17:36:33 +0800 Subject: [PATCH 1/7] =?UTF-8?q?security(auth):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=85=AC=E9=96=8B=20admin/register=20=E7=AB=AF=E9=BB=9E?= =?UTF-8?q?=EF=BC=8C=E6=94=B9=E7=94=B1=20app:create-admin=20=E5=BB=BA?= =?UTF-8?q?=E7=AB=8B=E7=AE=A1=E7=90=86=E5=93=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 漏洞:原 POST /api/admin/register 無任何防護,任何人可註冊管理員帳號。 - 移除路由、AuthController::registerAdmin、對應 Swagger 文件 - 新增 app:create-admin artisan command(密碼門檻 min:8) - 補 AdminAccountCreationTest 防止端點被重新打開 - 同步 admin-auth 規格,並收錄 2026-06-11 稽核報告與路線圖文檔 Co-Authored-By: Claude Fable 5 --- app/Console/Commands/CreateAdminUser.php | 62 ++++++++ app/Docs/AuthApiDoc.php | 52 ------- app/Http/Controllers/API/AuthController.php | 53 ------- .../2026-06-11-future-roadmap-feasibility.md | 100 +++++++++++++ .../2026-06-11-spec-implementation-audit.md | 140 ++++++++++++++++++ openspec/specs/admin-auth/spec.md | 17 +++ routes/api.php | 3 +- tests/Feature/AdminAccountCreationTest.php | 81 ++++++++++ 8 files changed, 401 insertions(+), 107 deletions(-) create mode 100644 app/Console/Commands/CreateAdminUser.php create mode 100644 docs/analysis/2026-06-11-future-roadmap-feasibility.md create mode 100644 docs/analysis/2026-06-11-spec-implementation-audit.md create mode 100644 tests/Feature/AdminAccountCreationTest.php diff --git a/app/Console/Commands/CreateAdminUser.php b/app/Console/Commands/CreateAdminUser.php new file mode 100644 index 0000000..62d6fad --- /dev/null +++ b/app/Console/Commands/CreateAdminUser.php @@ -0,0 +1,62 @@ +option('password') ?: $this->secret('請輸入密碼(至少 8 碼)'); + + $validator = Validator::make([ + 'name' => $this->argument('name'), + 'email' => $this->argument('email'), + 'password' => $password, + ], [ + 'name' => 'required|string|max:255', + 'email' => 'required|string|email|max:255|unique:users', + // 管理權限影響全平台,密碼門檻高於一般使用者的 min:6 + 'password' => 'required|string|min:8', + ]); + + if ($validator->fails()) { + foreach ($validator->errors()->all() as $error) { + $this->error($error); + } + + return self::FAILURE; + } + + $user = User::create([ + 'name' => $this->argument('name'), + 'email' => $this->argument('email'), + 'password' => Hash::make($password), + 'role' => 'admin', + ]); + + AdminProfile::create([ + 'user_id' => $user->id, + 'position' => $this->option('position'), + 'department' => $this->option('department'), + ]); + + $this->info("管理員帳號已建立:{$user->email}(id={$user->id})"); + + return self::SUCCESS; + } +} diff --git a/app/Docs/AuthApiDoc.php b/app/Docs/AuthApiDoc.php index e2d2c12..341c921 100644 --- a/app/Docs/AuthApiDoc.php +++ b/app/Docs/AuthApiDoc.php @@ -825,58 +825,6 @@ class AuthApiDoc { } - /** - * 管理員註冊 - * - * @OA\Post( - * path="/admin/register", - * summary="管理員註冊", - * description="建立新的管理員帳號", - * operationId="registerAdmin", - * tags={"管理員"}, - * @OA\RequestBody( - * required=true, - * @OA\JsonContent( - * required={"name", "email", "password", "password_confirmation"}, - * @OA\Property(property="name", type="string", example="張管理", description="使用者姓名"), - * @OA\Property(property="email", type="string", format="email", example="admin@example.com", description="電子郵件"), - * @OA\Property(property="password", type="string", format="password", example="password123", description="密碼"), - * @OA\Property(property="password_confirmation", type="string", format="password", example="password123", description="確認密碼"), - * @OA\Property(property="phone", type="string", example="0912345678", description="電話號碼"), - * @OA\Property(property="position", type="string", example="系統管理員", description="職位"), - * @OA\Property(property="department", type="string", example="IT部門", description="部門") - * ) - * ), - * @OA\Response( - * response=201, - * description="管理員註冊成功", - * @OA\JsonContent( - * @OA\Property(property="status", type="boolean", example=true), - * @OA\Property(property="message", type="string", example="管理員註冊成功"), - * @OA\Property( - * property="data", - * type="object", - * @OA\Property(property="user", type="object", ref="#/components/schemas/User"), - * @OA\Property(property="token", type="string", example="1|abcdef1234567890"), - * @OA\Property(property="token_type", type="string", example="Bearer") - * ) - * ) - * ), - * @OA\Response( - * response=422, - * description="驗證失敗", - * @OA\JsonContent( - * @OA\Property(property="status", type="boolean", example=false), - * @OA\Property(property="message", type="string", example="驗證失敗"), - * @OA\Property(property="errors", type="object") - * ) - * ) - * ) - */ - public function registerAdmin() - { - } - /** * 管理員登入 * diff --git a/app/Http/Controllers/API/AuthController.php b/app/Http/Controllers/API/AuthController.php index 859078e..cff3395 100644 --- a/app/Http/Controllers/API/AuthController.php +++ b/app/Http/Controllers/API/AuthController.php @@ -846,59 +846,6 @@ class AuthController extends Controller ]); } - /** - * 管理員註冊 - */ - public function registerAdmin(Request $request) - { - // 驗證請求數據 - $validator = Validator::make($request->all(), [ - 'name' => 'required|string|max:255', - 'email' => 'required|string|email|max:255|unique:users', - 'password' => 'required|string|min:6|confirmed', - 'phone' => 'nullable|string|max:20', - 'position' => 'nullable|string|max:100', - 'department' => 'nullable|string|max:100', - ]); - - if ($validator->fails()) { - return response()->json([ - 'status' => false, - 'message' => '驗證失敗', - 'errors' => $validator->errors() - ], 422); - } - - // 創建用戶 - $user = User::create([ - 'name' => $request->name, - 'email' => $request->email, - 'password' => Hash::make($request->password), - 'phone' => $request->phone, - 'role' => 'admin', // 強制為管理員角色 - ]); - - // 創建管理員資料 - AdminProfile::create([ - 'user_id' => $user->id, - 'position' => $request->position, - 'department' => $request->department, - ]); - - // 創建 API token - $token = $user->createToken('auth_token')->plainTextToken; - - return response()->json([ - 'status' => true, - 'message' => '管理員註冊成功', - 'data' => [ - 'user' => $user, - 'token' => $token, - 'token_type' => 'Bearer', - ] - ], 201); - } - /** * 管理員登入 */ diff --git a/docs/analysis/2026-06-11-future-roadmap-feasibility.md b/docs/analysis/2026-06-11-future-roadmap-feasibility.md new file mode 100644 index 0000000..39354a2 --- /dev/null +++ b/docs/analysis/2026-06-11-future-roadmap-feasibility.md @@ -0,0 +1,100 @@ +# CFDivePlatform 未來發展可行性評估 + +> 評估日期:2026-06-11 +> 前提:核心閉環(瀏覽 → 預約 → 聊天 → 完課 → 評價)已完成並歸檔;本文評估下一階段候選項目的可行性、依賴與建議順序。 +> 搭配閱讀:《2026-06-11-spec-implementation-audit.md》(修補計畫應先於所有新功能執行,尤其 P0-1)。 + +--- + +## 一、候選項目總覽 + +| # | 項目 | 可行性 | 預估工時 | 商業價值 | 建議優先序 | +|---|------|--------|---------|---------|-----------| +| 1 | 教練審核流程(完整版) | 高 | 3~5 天 | 高(信任基礎) | ★ 第 1 | +| 2 | 金流整合(綠界/藍新) | 中高 | 8~12 天 | 極高(變現核心) | ★ 第 2 | +| 3 | 即時聊天室 VPS 部署驗收 | 高 | 0.5~1 天 | 中(已開發未交付) | 隨時可做 | +| 4 | CI/CD(Gitea Actions)修復 | 中 | 1~2 天 | 中(工程效率) | 金流前必做 | +| 5 | 課程搜尋強化(地區/價格/日期篩選) | 高 | 2~3 天 | 中高 | 第 3 | +| 6 | 行動端體驗(PWA) | 中 | 5~8 天 | 中 | 觀望 | + +--- + +## 二、逐項評估 + +### 2.1 教練審核流程(完整版)— 可行性:高 + +**現況基礎**:`provider_profiles.is_verified` 欄位、Admin `toggle-verified` 端點、Admin Panel UI 均已存在;缺的是審核「流程」與約束力(見稽核報告 P1-1)。 + +**範圍建議**: +1. 教練註冊後狀態為 `pending_review`(建議將 boolean `is_verified` 升級為 enum `verification_status`:pending / approved / rejected)。 +2. 證照/資料上傳:複用既有 `CourseImageController` 的檔案處理模式(local storage,已有上傳驗證先例)。 +3. Admin 審核佇列頁 + 通過/駁回(含駁回原因),複用既有 Admin Panel 的列表/操作 UI 模式。 +4. 審核結果通知:複用 `notification-core` + `notification-email` 既有管線,只需新增 Notification class 與 trigger。 + +**技術風險**:低。所有積木(檔案上傳、通知、Admin UI、權限 middleware)都已存在,純組裝工作。 +**主要決策點**:未通過審核的教練能做到哪一步(可登入編輯資料但不可上架?完全不可登入?)——需產品決策,建議前者。 +**依賴**:先完成稽核報告 T2.1(最小語意),再擴充為完整流程,避免重工。 + +### 2.2 金流整合 — 可行性:中高(工程可行,依賴外部申請流程) + +**金流商比較(台灣市場)**: + +| 面向 | 綠界 ECPay | 藍新 NewebPay | +|------|-----------|---------------| +| 申請門檻 | 個人/公司皆可,測試環境免申請即用 | 偏好公司戶,測試環境需註冊 | +| 文件/社群資源 | Laravel 套件與範例多(生態最成熟) | 文件完整但第三方資源較少 | +| 手續費 | 信用卡約 2.75%~ | 相近 | +| 結論 | **建議首選**,沙箱即開即用、整合範例多 | 備選 | + +**對現有架構的影響(這是工時主要來源)**: +1. **預約狀態機需擴充**:現有七狀態未含付款概念,需插入 `awaiting_payment`、`paid` 等狀態或建立獨立 `payments` 資料表(建議後者:獨立 Payment model + 與 Booking 一對多,狀態機改動最小,符合開放封閉原則)。 +2. **Webhook 端點**:綠界以 server-side POST 回傳付款結果,需公開端點 + CheckMacValue 驗證 + 冪等處理(重複通知不可重複入帳)——此為資安重點,須走 CLAUDE.md 安全確認流程。 +3. **退款政策**:取消預約的退款規則(全額/比例/不退)是產品決策,直接影響狀態機與對帳邏輯,**須在開工前定案**。 +4. **金額快照**:現有 booking 已記錄價格快照(`booking-lifecycle` 規格),付款金額有可靠依據,這是現成優勢。 + +**前置條件**: +- [ ] 確定金流商並取得測試商店(綠界沙箱可立即取得)。 +- [ ] 退款政策定案。 +- [ ] **稽核報告 P0-1 必須先修**(金流上線後,管理端漏洞的損害會從資料外洩升級為金錢損失)。 +- [ ] 建議 CI 先行(2.4),金流程式碼回歸成本高,不應依賴手動測試。 + +**工時拆解(粗估)**:Payment model + 狀態整合 3 天、ECPay 串接與 Webhook 3 天、前端付款流程 2 天、測試(含冪等與失敗路徑)2~4 天。 + +### 2.3 即時聊天室 VPS 部署驗收 — 可行性:高 + +程式碼已 merge(commit 5ba598c 後系列),記憶中部署狀態未確認。剩餘工作為運維執行:rebuild frontend、restart reverb、`.env` 五個值(細節在既有部署清單中)。建議排入下次部署窗口一次完成,避免長期掛著「已完成未交付」狀態。 + +### 2.4 CI/CD(Gitea Actions)修復 — 可行性:中 + +已知 runner 的 self-hosted 標籤問題尚未解決。價值在於:測試套件已有 97 案例且將隨修補計畫 Phase 3 成長,沒有 CI 的測試套件會逐漸失去約束力。建議在金流開工前修復,作為金流開發的安全網。風險:Gitea runner 在 Windows/Docker 環境的相容性問題可能再耗時,若兩天內無法解決,備案是改用簡單的 pre-push hook 或部署前手動 gate。 + +### 2.5 課程搜尋強化 — 可行性:高 + +現有 `GET /api/diving-offers` 已支援關鍵字與分頁;擴充地區/價格區間/可預約日期篩選屬增量開發。注意事項:日期篩選需 join schedules 並留意 N+1 與索引(`db-index-optimization` 規格已有索引設計先例可循);快取層(`api-cache-layer`)的 cache key 需納入新篩選參數。 + +### 2.6 行動端 PWA — 可行性:中,建議觀望 + +Vue 3 + Vite 加 PWA plugin 技術上直接,但通知推播(Web Push)與 iOS 限制會吃掉多數工時,且現階段使用者基數未驗證。建議待金流上線、有真實流量後再評估。 + +--- + +## 三、建議路線圖 + +``` +Week 1 修補計畫 Phase 1~4(P0 封鎖 + 規格同步 + 預約測試) + └─ 並行:聊天室 VPS 部署驗收(2.3) +Week 2 教練審核流程完整版(2.1)+ CI 修復(2.4) +Week 3~4 金流整合(2.2):Payment model → ECPay 沙箱 → Webhook → 前端 +Week 5 金流測試強化 + 課程搜尋強化(2.5) +``` + +**關鍵判斷**: +1. 教練審核排在金流之前——付費平台上架未經審核的教練,發生糾紛時平台責任更重;審核機制是金流的信任前提。 +2. 金流是唯一含外部依賴(商店申請、Webhook 公網可達性)的項目,沙箱申請應在 Week 1 就先送出,與其他工作並行等待。 +3. 所有新功能開工前,稽核報告的 P0-1(admin register 漏洞)必須先關閉。 + +## 四、不建議現在做的事 + +- **微服務化 / 抽換架構**:單體 Laravel 在現階段流量下完全足夠,拆分只會增加部署與除錯成本。 +- **多金流商抽象層**:先用介面包一層 `PaymentGatewayContract` 即可(依賴反轉),但不要實作第二家金流——沒有需求前是投機性程式碼(違反 Simplicity First)。 +- **評價 AI 審核 / 推薦系統**:資料量不足,無訓練與評估基礎。 diff --git a/docs/analysis/2026-06-11-spec-implementation-audit.md b/docs/analysis/2026-06-11-spec-implementation-audit.md new file mode 100644 index 0000000..3ff1c89 --- /dev/null +++ b/docs/analysis/2026-06-11-spec-implementation-audit.md @@ -0,0 +1,140 @@ +# CFDivePlatform 規格與實作分析報告暨修補計畫 + +> 分析日期:2026-06-11 +> 分析範圍:`openspec/specs/`(32 份規格)vs 後端實作(Laravel 11)、前端(`frontend/` Vue 3)、測試(`tests/`) +> 分析基準:branch `test/auth-tests-coverage`(commit ca5b843,與 master 同步) + +--- + +## 一、現況總覽 + +### 1.1 規格狀態 + +- OpenSpec 規格共 **32 份**,全部 change 已歸檔(`openspec/changes/` 僅剩 `archive/`,無進行中 change)。 +- 涵蓋範圍:認證安全(lockout / rate-limiting / OAuth state / token refresh)、三角色入口(Member / Coach / Admin)、預約生命週期、評價系統、通知系統、即時聊天、Swagger 文件、效能優化。 + +### 1.2 實作狀態 + +| 模組 | 規格 | 實作 | 測試 | +|------|------|------|------| +| 認證(三角色登入/註冊/登出) | ✅ | ✅ | ✅ 39 案例 | +| 帳號鎖定 + OAuth state | ✅ | ✅ | ✅ 15 案例 | +| Token Refresh | ✅ | ✅ | ✅ 11 案例 | +| 課程 CRUD + 圖片上傳 | ✅ | ✅ | ✅ 14 案例 | +| 開課時段管理 | ✅ | ✅ | ❌ 無 | +| 預約生命週期(七狀態機、防超賣、Scheduler) | ✅ | ✅ | ❌ 無 | +| 評價系統(匿名、投票、rating 重算) | ✅ | ✅ | ✅ 32 案例 | +| 通知系統(站內 + Email) | ✅ | ✅ | ❌ 無 | +| 即時聊天 + Presence | ✅ | ✅ | ❌ 無 | +| Admin 管理端點(stats / users / offers / bookings / reviews) | ✅ | ✅ | ❌ 無 | +| 教練審核流程 | ❌ 無規格 | ⚠️ 僅半套(見 P1-1) | ❌ 無 | +| 金流整合 | ❌ 無規格 | ❌ 未實作 | — | + +整體而言規格與實作的對應度高,路由、端點、middleware 配置大致與規格一致。以下列出本次稽核發現的偏差與風險。 + +--- + +## 二、發現問題(依嚴重度排序) + +### 🔴 P0-1:`POST /api/admin/register` 完全公開,任何人可註冊管理員帳號 + +- **位置**:`routes/api.php:106`、`AuthController::registerAdmin()`(`AuthController.php:852`) +- **事實**:該路由無任何 middleware、無邀請碼、無「僅限第一位管理員」檢查;request 通過基本驗證後直接 `role => 'admin'` 建立帳號。 +- **影響**:攻擊者一個 HTTP 請求即可取得全平台管理權限(停權用戶、刪課程、刪評價、看全部個資),**使 P0~P2 所有認證安全強化形同虛設**。 +- **規格對應**:`admin-auth` 規格只定義「管理員登入」,**從未定義公開註冊端點**——此端點本身就是規格外實作。 +- **附帶問題**:密碼規則僅 `min:6`,低於一般管理帳號標準。 + +### 🟠 P1-1:`is_verified`(教練驗證)從未被任何業務邏輯強制執行 + +- **事實**:`is_verified` 只出現在兩處——Admin 的 `toggleProviderVerified()`(`AdminUserController.php:144`)與 Model 屬性定義。教練**註冊後不需任何審核即可登入、上架課程、被公開列表曝光、接受預約**。 +- **影響**:Admin 後台的「驗證教練」開關是純展示功能,平台對教練資質零把關。這就是記憶中「教練審核流程未實作」的實際狀態:開關存在、約束不存在。 +- **規格對應**:目前**沒有任何規格**定義 `is_verified` 的業務語意(未驗證教練能做什麼/不能做什麼),屬規格空洞。 + +### 🟠 P1-2:登入頻率限制規格與實作不一致(規格漂移) + +- **規格**(`openspec/specs/login-rate-limiting/spec.md`):Member / Provider 每 IP 每分鐘最多 **5 次**。 +- **實作**(`routes/api.php:31,67`):`throttle:10,1`,即 **10 次**。 +- **測試**(`AuthRateLimitTest.php`):已在 commit 0dabc4e 改為斷言 10 次,**與實作一致、與規格矛盾**。 +- **影響**:規格失去單一真實來源(single source of truth)地位。未來任何人依規格實作或稽核都會得出錯誤結論。Admin 的 3 次限制則規格實作一致。 + +### 🟡 P2-1:核心業務流程(預約)零測試覆蓋 + +- 現有 97 個測試案例集中在認證(70)、評價(32 中含投票)、圖片(14)。 +- **預約狀態機(七狀態)、防超賣鎖定、Scheduler 自動過期/完成**(`ExpirePendingBookings`、`CompleteFinishedBookings`)——平台最核心、含金量最高、最容易因修改而回歸的邏輯——**沒有任何一條測試**。 +- 同樣無測試:時段管理(時段重疊/容量)、通知觸發、聊天授權(presence channel 參與方驗證)、全部 Admin 端點(含 P0-1 的權限邊界)。 +- 依 CLAUDE.md Rule 9,這些測試需編碼「為什麼」:防超賣測試保護的是金錢與信任,狀態機測試保護的是不可逆操作的合法轉移。 + +### 🟡 P2-2:規格維護債 + +1. `auth-test-coverage/spec.md` 的 Purpose 仍是 `TBD - created by archiving change`,歸檔後未補。 +2. `member-portal-ui` 規格寫「前端 SHALL 建立於獨立 repo」,實際前端在本 repo 的 `frontend/` 目錄——規格描述已過時。 +3. `admin-auth` 規格未涵蓋實際存在的 `/admin/register`、`/admin/check-member/{id}`、`/admin/check-provider/{id}` 端點。 + +--- + +## 三、修補計畫 + +### Phase 1 — 立即(建議今天,0.5 天內) + +**T1.1 封鎖 `POST /api/admin/register`**(對應 P0-1) + +建議方案(擇一,建議 a): +- (a) **移除公開路由**,改為 artisan command `php artisan admin:create`(僅能從主機執行),生產環境的管理員一律由既有管理員或主機端建立。 +- (b) 移入 `auth:sanctum + admin` middleware group(僅既有 admin 可開新 admin)——但需先用 (a) 或 seeder 解決第一位管理員的雞生蛋問題。 + +驗收標準: +- [ ] 未認證請求 `POST /api/admin/register` 回傳 404 或 401(不再是 201)。 +- [ ] 補 Feature test:未授權者無法建立 admin 帳號。 +- [ ] Demo seeder 既有的 admin 帳號不受影響。 +- [ ] 同步新增/更新 `admin-auth` 規格段落,明確定義管理員帳號建立途徑。 + +**T1.2 解決 rate-limiting 規格漂移**(對應 P1-2) + +- 先決定基準:5/min(規格)或 10/min(實作)。10/min 是後來有意調整(測試已配合修改),建議**以實作為準、更新規格**;若當初是誤改,則改回 5 並還原測試。 +- 驗收標準:規格、`routes/api.php`、`AuthRateLimitTest` 三者數字一致。 + +### Phase 2 — 短期(1~2 天) + +**T2.1 賦予 `is_verified` 最小業務語意**(對應 P1-1,為完整教練審核流程鋪路) + +最小可行約束(不做完整審核流程,僅堵住風險): +- 未驗證 Provider 的課程不出現在公開列表 `GET /api/diving-offers`(或:未驗證 Provider 無法將課程上架/`store()` 回 403,二擇一,依產品決策)。 +- 課程詳情頁顯示教練 `is_verified` 徽章(前端已有欄位可用)。 +- 驗收標準: + - [ ] 新增規格 `provider-verification`(最小版),定義未驗證教練的能力邊界。 + - [ ] Feature test:未驗證教練的課程不可被公開查詢(或不可建立)。 + - [ ] Admin `toggle-verified` 後行為立即生效(注意 `api-cache-layer` 的快取失效)。 + +> 完整審核流程(證照上傳、審核佇列、駁回原因)屬新功能,見《未來發展可行性評估》文檔。 + +### Phase 3 — 中期(2~3 天) + +**T3.1 補預約核心測試**(對應 P2-1,優先序由高至低) + +1. `BookingLifecycleTest`:七狀態機合法/非法轉移(pending→confirmed→completed、reject、cancel、不可逆狀態防護)。 +2. `BookingOversellTest`:併發/邊界情境下 confirmed 不超過時段容量;pending 不佔名額。 +3. `BookingSchedulerTest`:`app:expire-pending-bookings` 與 `app:complete-finished-bookings` 的時間邊界。 +4. `BookingChatAuthTest`:非參與方無法讀寫訊息、無法訂閱 presence channel。 +5. `AdminEndpointTest`:非 admin token 存取 `/api/admin/*` 一律 403。 + +驗收標準:以上測試全綠且任一狀態機規則被改壞時至少一條測試會失敗(mutation 自查)。 + +### Phase 4 — 規格清理(0.5 天,可與 Phase 3 並行) + +- [ ] 補 `auth-test-coverage` 的 Purpose。 +- [ ] 更新 `member-portal-ui` 的 repo 描述。 +- [ ] `admin-auth` 補 `check-member` / `check-provider` 端點描述(`register` 端點依 T1.1 決策寫入或標記移除)。 + +--- + +## 四、風險與依賴 + +| 項目 | 風險 | 緩解 | +|------|------|------| +| T1.1 | 前端 Admin 註冊頁(若存在)會失效 | 檢查 `frontend/src/views/admin/`,同步移除入口 | +| T2.1 | 公開列表過濾可能影響 demo 資料展示 | Demo seeder 將教練預設 `is_verified=true` | +| T3.1 | 測試需 MySQL 容器運行 | 沿用既有 `php artisan test` 容器內流程 | + +## 五、規格同步狀態聲明 + +本報告本身不改動程式碼。執行修補計畫時需同步的規格文件:`admin-auth`(T1.1)、`login-rate-limiting`(T1.2)、新增 `provider-verification`(T2.1)、`auth-test-coverage` 與 `member-portal-ui`(Phase 4)。 diff --git a/openspec/specs/admin-auth/spec.md b/openspec/specs/admin-auth/spec.md index e331217..8151cb1 100644 --- a/openspec/specs/admin-auth/spec.md +++ b/openspec/specs/admin-auth/spec.md @@ -1,5 +1,22 @@ ## ADDED Requirements +### Requirement: 管理員帳號建立途徑 +管理員帳號 SHALL 僅能透過主機端 `php artisan app:create-admin` command 或資料庫 seeder 建立。系統 MUST NOT 提供任何公開的管理員註冊 HTTP 端點(原 `POST /api/admin/register` 已於 2026-06-11 因 P0 安全漏洞移除)。command 建立的密碼門檻為至少 8 碼,高於一般使用者。 + +#### Scenario: 公開註冊端點保持關閉 +- **WHEN** 任何人(含未認證請求)送出 `POST /api/admin/register` +- **THEN** 回傳 HTTP 404,且不建立任何帳號 + +#### Scenario: 主機端建立管理員成功 +- **WHEN** 操作者於主機執行 `php artisan app:create-admin {name} {email} --password={password}` 且資料合法 +- **THEN** 建立 role=admin 的 User 與對應 AdminProfile + +#### Scenario: 密碼過弱或 email 重複 +- **WHEN** command 收到少於 8 碼的密碼或已存在的 email +- **THEN** command 以失敗結束,不建立任何帳號 + +--- + ### Requirement: 管理員登入 後端 SHALL 提供 `POST /api/admin/login`(現有 AuthController 方法),驗證 email/password 並確認 role=admin,回傳有效期 7 天的 Bearer token。 diff --git a/routes/api.php b/routes/api.php index 82e04dc..400ad5e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -102,8 +102,7 @@ Route::middleware(['auth:sanctum'])->prefix('provider')->group(function () { Route::put('/bookings/{id}/complete', [ProviderBookingController::class, 'complete']); }); -// 管理員註冊/登入 -Route::post('/admin/register', [AuthController::class, 'registerAdmin']); +// 管理員登入(帳號建立僅限主機端 php artisan app:create-admin,不提供公開註冊) Route::middleware('throttle:3,1')->post('/admin/login', [AuthController::class, 'loginAdmin']); // 管理員專屬 API(需登入) diff --git a/tests/Feature/AdminAccountCreationTest.php b/tests/Feature/AdminAccountCreationTest.php new file mode 100644 index 0000000..288917e --- /dev/null +++ b/tests/Feature/AdminAccountCreationTest.php @@ -0,0 +1,81 @@ +postJson('/api/admin/register', [ + 'name' => 'Evil Admin', + 'email' => 'evil@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]); + + $response->assertStatus(404); + $this->assertDatabaseMissing('users', ['email' => 'evil@example.com']); + $this->assertDatabaseMissing('users', ['email' => 'evil@example.com', 'role' => 'admin']); + } + + // ── artisan command 為唯一建立途徑 ─────────────────────── + + public function test_create_admin_command_creates_admin_with_profile(): void + { + $this->artisan('app:create-admin', [ + 'name' => 'Ops Admin', + 'email' => 'ops@example.com', + '--password' => 'StrongPass99', + ])->assertSuccessful(); + + $this->assertDatabaseHas('users', [ + 'email' => 'ops@example.com', + 'role' => 'admin', + ]); + + $admin = User::where('email', 'ops@example.com')->first(); + $this->assertDatabaseHas('admin_profiles', ['user_id' => $admin->id]); + } + + public function test_create_admin_command_rejects_duplicate_email(): void + { + User::factory()->create(['email' => 'exists@example.com']); + + $this->artisan('app:create-admin', [ + 'name' => 'Dup Admin', + 'email' => 'exists@example.com', + '--password' => 'StrongPass99', + ])->assertFailed(); + + $this->assertDatabaseMissing('users', [ + 'email' => 'exists@example.com', + 'role' => 'admin', + ]); + } + + public function test_create_admin_command_rejects_weak_password(): void + { + $this->artisan('app:create-admin', [ + 'name' => 'Weak Admin', + 'email' => 'weak@example.com', + '--password' => 'short', + ])->assertFailed(); + + $this->assertDatabaseMissing('users', ['email' => 'weak@example.com']); + } +} From 88a81aac41a33c4842aa9d0928743da40329d3aa Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 11 Jun 2026 17:37:19 +0800 Subject: [PATCH 2/7] =?UTF-8?q?docs(openspec):=20=E5=90=8C=E6=AD=A5=20logi?= =?UTF-8?q?n-rate-limiting=20=E8=A6=8F=E6=A0=BC=E9=96=80=E6=AA=BB=E8=87=B3?= =?UTF-8?q?=E5=AF=A6=E4=BD=9C=E5=80=BC=2010/min?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 規格原寫 5/min,實作與測試(commit 0dabc4e)皆為 throttle:10,1, 以實作為準消除規格漂移,並註記放寬理由(帳號鎖定已涵蓋暴力破解防護)。 Co-Authored-By: Claude Fable 5 --- openspec/specs/login-rate-limiting/spec.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openspec/specs/login-rate-limiting/spec.md b/openspec/specs/login-rate-limiting/spec.md index 426330f..3b2cfb8 100644 --- a/openspec/specs/login-rate-limiting/spec.md +++ b/openspec/specs/login-rate-limiting/spec.md @@ -1,14 +1,14 @@ ## ADDED Requirements ### Requirement: 登入頻率限制 -後端 SHALL 對所有登入端點套用 IP-based 頻率限制,超過限制時回傳 HTTP 429。Member 與 Provider 每 IP 每分鐘最多 5 次;Admin 因影響範圍更廣,限制為每 IP 每分鐘最多 3 次。 +後端 SHALL 對所有登入端點套用 IP-based 頻率限制,超過限制時回傳 HTTP 429。Member 與 Provider 每 IP 每分鐘最多 10 次(原定 5 次;帳號鎖定機制上線後已涵蓋暴力破解防護,放寬以容納共享 IP 場景,與實作 `throttle:10,1` 一致);Admin 因影響範圍更廣,限制為每 IP 每分鐘最多 3 次。 #### Scenario: Member / Provider 正常登入不受影響 -- **WHEN** 同一 IP 在 1 分鐘內對 `/api/member/login` 或 `/api/provider/login` 送出 5 次以內的請求 +- **WHEN** 同一 IP 在 1 分鐘內對 `/api/member/login` 或 `/api/provider/login` 送出 10 次以內的請求 - **THEN** 請求正常處理,回傳對應的登入結果(200 成功或 401 失敗) #### Scenario: Member / Provider 超過頻率限制 -- **WHEN** 同一 IP 在 1 分鐘內送出第 6 次 member 或 provider 登入請求 +- **WHEN** 同一 IP 在 1 分鐘內送出第 11 次 member 或 provider 登入請求 - **THEN** 回傳 HTTP 429,並帶有 `Retry-After` header 指示等待時間 #### Scenario: Admin 超過頻率限制 From 3c38d085bdc435c657a18be622ae596134e8a95e Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 11 Jun 2026 17:42:43 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat(offers):=20=E6=9C=AA=E9=A9=97=E8=AD=89?= =?UTF-8?q?=E6=95=99=E7=B7=B4=E7=9A=84=E8=AA=B2=E7=A8=8B=E4=B8=8D=E5=B0=8D?= =?UTF-8?q?=E5=85=AC=E9=96=8B=E7=AB=AF=E9=BB=9E=E6=9B=9D=E5=85=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_verified 原本只有 Admin toggle 開關、無任何業務約束(稽核 P1-1)。 - DivingOffer 新增 visibleToPublic scope:provider_id null 或教練已驗證 - 公開 index/show 套用過濾,未驗證教練課程列表排除、詳情 404 - toggle-verified 後 flush diving_offers 快取標籤,切換立即生效 - 新增 provider-verification 規格(含已知限制註記)與 7 條可見性測試 Co-Authored-By: Claude Fable 5 --- .../Controllers/API/AdminUserController.php | 4 + .../Controllers/API/DivingOfferController.php | 4 +- app/Models/DivingOffer.php | 13 ++ openspec/specs/provider-verification/spec.md | 48 ++++++ tests/Feature/DivingOfferVisibilityTest.php | 138 ++++++++++++++++++ 5 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 openspec/specs/provider-verification/spec.md create mode 100644 tests/Feature/DivingOfferVisibilityTest.php diff --git a/app/Http/Controllers/API/AdminUserController.php b/app/Http/Controllers/API/AdminUserController.php index f1bcb7b..9814760 100644 --- a/app/Http/Controllers/API/AdminUserController.php +++ b/app/Http/Controllers/API/AdminUserController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\API; use App\Http\Controllers\Controller; use App\Models\User; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; class AdminUserController extends Controller { @@ -144,6 +145,9 @@ class AdminUserController extends Controller $profile->is_verified = !$profile->is_verified; $profile->save(); + // 驗證狀態影響公開課程列表的可見性,需立即讓快取失效 + Cache::tags(['diving_offers'])->flush(); + $msg = $profile->is_verified ? '教練已驗證' : '已取消驗證'; return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_verified' => $profile->is_verified]]); } diff --git a/app/Http/Controllers/API/DivingOfferController.php b/app/Http/Controllers/API/DivingOfferController.php index 96d6e33..0889e1d 100644 --- a/app/Http/Controllers/API/DivingOfferController.php +++ b/app/Http/Controllers/API/DivingOfferController.php @@ -15,7 +15,7 @@ class DivingOfferController extends Controller $cacheKey = 'diving_offers_' . md5(serialize($request->all())); $result = Cache::tags(['diving_offers'])->remember($cacheKey, 180, function () use ($request, $perPage) { - $query = DivingOffer::query(); + $query = DivingOffer::query()->visibleToPublic(); if ($q = $request->query('q')) { $query->where(function ($sub) use ($q) { @@ -55,7 +55,7 @@ class DivingOfferController extends Controller public function show(int $id) { - $offer = DivingOffer::with('courseImages')->find($id); + $offer = DivingOffer::with('courseImages')->visibleToPublic()->find($id); if (!$offer) { return response()->json(['status' => false, 'message' => '課程不存在'], 404); diff --git a/app/Models/DivingOffer.php b/app/Models/DivingOffer.php index e1816d4..b552b52 100644 --- a/app/Models/DivingOffer.php +++ b/app/Models/DivingOffer.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Facades\Storage; @@ -53,6 +54,18 @@ class DivingOffer extends Model return $this->belongsTo(User::class, 'provider_id'); } + /** + * 公開端點可見性:未驗證教練的課程不對外曝光。 + * provider_id 為 null 的課程(平台自有資料)不受此限制。 + */ + public function scopeVisibleToPublic(Builder $query): Builder + { + return $query->where(function (Builder $visible) { + $visible->whereNull('provider_id') + ->orWhereHas('provider.providerProfile', fn (Builder $profile) => $profile->where('is_verified', true)); + }); + } + public function schedules() { return $this->hasMany(CourseSchedule::class, 'diving_offer_id'); diff --git a/openspec/specs/provider-verification/spec.md b/openspec/specs/provider-verification/spec.md new file mode 100644 index 0000000..6a84b58 --- /dev/null +++ b/openspec/specs/provider-verification/spec.md @@ -0,0 +1,48 @@ +# provider-verification Specification + +## Purpose + +定義 `provider_profiles.is_verified` 的最小業務語意:未通過平台驗證的教練,其課程不對公開端點曝光。在完整教練審核流程(證照上傳、審核佇列、駁回原因)實作前,先以此約束堵住「未審核教練可公開曝光」的風險。 + +## ADDED Requirements + +### Requirement: 未驗證教練的課程不對公開端點曝光 +公開課程端點(`GET /api/diving-offers`、`GET /api/diving-offers/{id}`)SHALL 僅回傳符合以下任一條件的課程:(a) `provider_id` 為 null(平台自有資料);(b) 課程所屬 Provider 的 `provider_profiles.is_verified = true`。未驗證教練的課程在列表中 SHALL 被排除,在詳情端點 SHALL 回傳 404。 + +#### Scenario: 已驗證教練的課程正常曝光 +- **WHEN** 匿名使用者請求 `GET /api/diving-offers` +- **THEN** 已驗證教練(is_verified=true)的課程出現在結果中 + +#### Scenario: 未驗證教練的課程從列表排除 +- **WHEN** 匿名使用者請求 `GET /api/diving-offers` +- **THEN** 未驗證教練(is_verified=false 或無 ProviderProfile)的課程不出現在結果中 + +#### Scenario: 未驗證教練的課程詳情回 404 +- **WHEN** 匿名使用者請求 `GET /api/diving-offers/{id}`,該課程屬於未驗證教練 +- **THEN** 回傳 HTTP 404 + +#### Scenario: provider_id 為 null 的課程不受限 +- **WHEN** 匿名使用者請求公開課程端點,課程的 `provider_id` 為 null +- **THEN** 課程正常曝光 + +--- + +### Requirement: 驗證狀態切換立即生效 +管理員透過 `PUT /api/admin/providers/{id}/toggle-verified` 切換驗證狀態後,公開課程列表的快取(`diving_offers` cache tag)SHALL 立即失效,下次請求反映最新可見性。 + +#### Scenario: 取消驗證後課程立即從公開列表消失 +- **WHEN** 管理員將教練 is_verified 由 true 切為 false +- **THEN** 下一次 `GET /api/diving-offers` 請求不包含該教練的課程(不受 180 秒快取影響) + +--- + +### Requirement: 教練自有管理端點不受可見性限制 +Provider 對自己課程的管理端點(`/api/provider/offers*`)與 Admin 管理端點(`/api/admin/offers*`)SHALL 不受公開可見性過濾影響,未驗證教練仍可登入、編輯與管理自己的課程。 + +#### Scenario: 未驗證教練仍可管理自己的課程 +- **WHEN** 未驗證教練以有效 token 請求 `GET /api/provider/offers` +- **THEN** 回傳該教練的全部課程 + +## Notes + +已知限制(留待完整教練審核流程處理):`GET /api/diving-offers/{id}/schedules`、`GET /api/diving-offers/{id}/reviews` 與預約建立流程尚未套用相同過濾,知道課程 id 的使用者仍可間接互動。完整審核流程(verification_status enum、證照上傳、審核佇列)見 `docs/analysis/2026-06-11-future-roadmap-feasibility.md` §2.1。 diff --git a/tests/Feature/DivingOfferVisibilityTest.php b/tests/Feature/DivingOfferVisibilityTest.php new file mode 100644 index 0000000..f6d40da --- /dev/null +++ b/tests/Feature/DivingOfferVisibilityTest.php @@ -0,0 +1,138 @@ +create(['role' => 'provider']); + + ProviderProfile::create([ + 'user_id' => $provider->id, + 'is_verified' => $isVerified, + ]); + + return $provider; + } + + private function makeOffer(?int $providerId, string $title): DivingOffer + { + return DivingOffer::create([ + 'provider_id' => $providerId, + 'title' => $title, + 'location' => 'Test', + 'spot' => 'Test Spot', + 'price' => 1000, + 'region' => '南部', + 'rating' => 0, + 'reviews' => 0, + ]); + } + + // ── 公開列表 ───────────────────────────────────────────── + + public function test_index_includes_verified_provider_offers(): void + { + $verified = $this->makeProvider(true); + $this->makeOffer($verified->id, 'Verified Course'); + + $response = $this->getJson('/api/diving-offers'); + + $response->assertOk(); + $this->assertContains('Verified Course', array_column($response->json('data'), 'title')); + } + + public function test_index_excludes_unverified_provider_offers(): void + { + $unverified = $this->makeProvider(false); + $this->makeOffer($unverified->id, 'Unverified Course'); + + $response = $this->getJson('/api/diving-offers'); + + $response->assertOk(); + $this->assertNotContains('Unverified Course', array_column($response->json('data'), 'title')); + } + + public function test_index_includes_platform_owned_offers_without_provider(): void + { + $this->makeOffer(null, 'Platform Course'); + + $response = $this->getJson('/api/diving-offers'); + + $response->assertOk(); + $this->assertContains('Platform Course', array_column($response->json('data'), 'title')); + } + + // ── 課程詳情 ───────────────────────────────────────────── + + public function test_show_returns_404_for_unverified_provider_offer(): void + { + $unverified = $this->makeProvider(false); + $offer = $this->makeOffer($unverified->id, 'Hidden Course'); + + $this->getJson("/api/diving-offers/{$offer->id}")->assertStatus(404); + } + + public function test_show_returns_verified_provider_offer(): void + { + $verified = $this->makeProvider(true); + $offer = $this->makeOffer($verified->id, 'Visible Course'); + + $this->getJson("/api/diving-offers/{$offer->id}") + ->assertOk() + ->assertJsonPath('data.title', 'Visible Course'); + } + + // ── 驗證狀態切換立即生效(快取失效) ───────────────────── + + public function test_toggle_verified_takes_effect_immediately_despite_cache(): void + { + $provider = $this->makeProvider(true); + $this->makeOffer($provider->id, 'Toggle Course'); + $admin = User::factory()->create(['role' => 'admin']); + + // 先打一次列表讓結果進快取 + $this->assertContains( + 'Toggle Course', + array_column($this->getJson('/api/diving-offers')->json('data'), 'title') + ); + + $this->actingAs($admin) + ->putJson("/api/admin/providers/{$provider->id}/toggle-verified") + ->assertOk() + ->assertJsonPath('data.is_verified', false); + + $this->assertNotContains( + 'Toggle Course', + array_column($this->getJson('/api/diving-offers')->json('data'), 'title') + ); + } + + // ── 教練自有管理端點不受限 ─────────────────────────────── + + public function test_unverified_provider_can_still_manage_own_offers(): void + { + $unverified = $this->makeProvider(false); + $this->makeOffer($unverified->id, 'My Own Course'); + + $response = $this->actingAs($unverified)->getJson('/api/provider/offers'); + + $response->assertOk(); + $this->assertContains('My Own Course', array_column($response->json('data'), 'title')); + } +} From 63b25f96ace00b96b81d2f3c38f8181a79fa3a83 Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 11 Jun 2026 17:46:46 +0800 Subject: [PATCH 4/7] =?UTF-8?q?test(booking):=20=E8=A3=9C=E9=A0=90?= =?UTF-8?q?=E7=B4=84=E6=A0=B8=E5=BF=83=E6=B8=AC=E8=A9=A6=E2=80=94=E2=80=94?= =?UTF-8?q?=E7=8B=80=E6=85=8B=E6=A9=9F/=E9=98=B2=E8=B6=85=E8=B3=A3/Schedul?= =?UTF-8?q?er/=E8=81=8A=E5=A4=A9=E6=8E=88=E6=AC=8A/Admin=20=E6=AC=8A?= =?UTF-8?q?=E9=99=90=E9=82=8A=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 稽核 P2-1:平台核心業務(預約)原本零測試覆蓋。 - BookingLifecycleTest:七狀態機合法/非法轉移、名額帳務、24h 取消截止、授權邊界(17 案例) - BookingOversellTest:confirm 時 lockForUpdate 防超賣不變式、名額釋放回收(3 案例) - BookingSchedulerTest:48h 過期與日期完成的時間邊界(6 案例) - BookingChatAuthTest:HTTP 與 presence channel 雙防線(8 案例) - AdminEndpointAuthTest:非 admin 一律 403、Admin 不可繞過狀態機(5 案例) Co-Authored-By: Claude Fable 5 --- tests/Feature/AdminEndpointAuthTest.php | 122 +++++++++ tests/Feature/BookingChatAuthTest.php | 148 +++++++++++ tests/Feature/BookingLifecycleTest.php | 334 ++++++++++++++++++++++++ tests/Feature/BookingOversellTest.php | 143 ++++++++++ tests/Feature/BookingSchedulerTest.php | 134 ++++++++++ 5 files changed, 881 insertions(+) create mode 100644 tests/Feature/AdminEndpointAuthTest.php create mode 100644 tests/Feature/BookingChatAuthTest.php create mode 100644 tests/Feature/BookingLifecycleTest.php create mode 100644 tests/Feature/BookingOversellTest.php create mode 100644 tests/Feature/BookingSchedulerTest.php diff --git a/tests/Feature/AdminEndpointAuthTest.php b/tests/Feature/AdminEndpointAuthTest.php new file mode 100644 index 0000000..a570265 --- /dev/null +++ b/tests/Feature/AdminEndpointAuthTest.php @@ -0,0 +1,122 @@ +create(['role' => 'member']); + + foreach (self::ADMIN_GET_ENDPOINTS as $endpoint) { + $this->actingAs($member)->getJson($endpoint)->assertStatus(403); + } + } + + public function test_provider_token_is_rejected_on_all_admin_endpoints(): void + { + $provider = User::factory()->create(['role' => 'provider']); + + foreach (self::ADMIN_GET_ENDPOINTS as $endpoint) { + $this->actingAs($provider)->getJson($endpoint)->assertStatus(403); + } + } + + public function test_unauthenticated_request_is_rejected_on_all_admin_endpoints(): void + { + foreach (self::ADMIN_GET_ENDPOINTS as $endpoint) { + $this->getJson($endpoint)->assertStatus(401); + } + } + + public function test_admin_token_can_access_admin_endpoints(): void + { + $admin = User::factory()->create(['role' => 'admin']); + + foreach (self::ADMIN_GET_ENDPOINTS as $endpoint) { + $this->actingAs($admin)->getJson($endpoint)->assertOk(); + } + } + + public function test_admin_complete_follows_booking_state_machine(): void + { + $admin = User::factory()->create(['role' => 'admin']); + $provider = User::factory()->create(['role' => 'provider']); + $member = User::factory()->create(['role' => 'member']); + + $offer = DivingOffer::create([ + 'provider_id' => $provider->id, + 'title' => 'Admin Course', + 'location' => 'Test', + 'spot' => 'Test Spot', + 'price' => 1000, + 'region' => '南部', + 'rating' => 0, + 'reviews' => 0, + ]); + + $schedule = CourseSchedule::create([ + 'diving_offer_id' => $offer->id, + 'provider_id' => $provider->id, + 'scheduled_date' => now()->subDay()->toDateString(), + 'start_time' => '09:00', + 'max_participants' => 4, + 'current_participants' => 1, + 'status' => ScheduleStatus::Open, + ]); + + $confirmed = Booking::create([ + 'schedule_id' => $schedule->id, + 'member_id' => $member->id, + 'participants' => 1, + 'total_price' => 1000, + 'status' => BookingStatus::Confirmed, + ]); + + $this->actingAs($admin) + ->putJson("/api/admin/bookings/{$confirmed->id}/complete") + ->assertOk(); + $this->assertSame(BookingStatus::Completed, $confirmed->fresh()->status); + + // Admin 也不能繞過狀態機:rejected 是終態 + $rejected = Booking::create([ + 'schedule_id' => $schedule->id, + 'member_id' => User::factory()->create(['role' => 'member'])->id, + 'participants' => 1, + 'total_price' => 1000, + 'status' => BookingStatus::Rejected, + ]); + + $this->actingAs($admin) + ->putJson("/api/admin/bookings/{$rejected->id}/complete") + ->assertStatus(422); + $this->assertSame(BookingStatus::Rejected, $rejected->fresh()->status); + } +} diff --git a/tests/Feature/BookingChatAuthTest.php b/tests/Feature/BookingChatAuthTest.php new file mode 100644 index 0000000..e77d93e --- /dev/null +++ b/tests/Feature/BookingChatAuthTest.php @@ -0,0 +1,148 @@ +member = User::factory()->create(['role' => 'member']); + $this->provider = User::factory()->create(['role' => 'provider']); + + $offer = DivingOffer::create([ + 'provider_id' => $this->provider->id, + 'title' => 'Chat Course', + 'location' => 'Test', + 'spot' => 'Test Spot', + 'price' => 1000, + 'region' => '南部', + 'rating' => 0, + 'reviews' => 0, + ]); + + $schedule = CourseSchedule::create([ + 'diving_offer_id' => $offer->id, + 'provider_id' => $this->provider->id, + 'scheduled_date' => now()->addDays(7)->toDateString(), + 'start_time' => '09:00', + 'max_participants' => 4, + 'current_participants' => 1, + 'status' => ScheduleStatus::Open, + ]); + + $this->booking = Booking::create([ + 'schedule_id' => $schedule->id, + 'member_id' => $this->member->id, + 'participants' => 1, + 'total_price' => 1000, + 'status' => BookingStatus::Confirmed, + ]); + } + + // ── HTTP 端點授權 ──────────────────────────────────────── + + public function test_participants_can_read_messages_on_confirmed_booking(): void + { + $this->actingAs($this->member) + ->getJson("/api/bookings/{$this->booking->id}/messages") + ->assertOk(); + + $this->actingAs($this->provider) + ->getJson("/api/bookings/{$this->booking->id}/messages") + ->assertOk(); + } + + public function test_outsider_member_cannot_read_messages(): void + { + $outsider = User::factory()->create(['role' => 'member']); + + $this->actingAs($outsider) + ->getJson("/api/bookings/{$this->booking->id}/messages") + ->assertStatus(403); + } + + public function test_other_provider_cannot_read_messages(): void + { + $otherProvider = User::factory()->create(['role' => 'provider']); + + $this->actingAs($otherProvider) + ->getJson("/api/bookings/{$this->booking->id}/messages") + ->assertStatus(403); + } + + public function test_messages_forbidden_on_pending_booking(): void + { + $this->booking->update(['status' => BookingStatus::Pending]); + + $this->actingAs($this->member) + ->getJson("/api/bookings/{$this->booking->id}/messages") + ->assertStatus(403); + } + + public function test_outsider_cannot_send_message(): void + { + $outsider = User::factory()->create(['role' => 'member']); + + $this->actingAs($outsider) + ->postJson("/api/bookings/{$this->booking->id}/messages", ['body' => 'hi']) + ->assertStatus(403); + } + + // ── Presence channel 訂閱驗證 ──────────────────────────── + + public function test_presence_channel_admits_both_participants(): void + { + $channel = new BookingPresenceChannel(); + + $memberResult = $channel->join($this->member, $this->booking); + $this->assertIsArray($memberResult); + $this->assertSame($this->member->id, $memberResult['user_id']); + + $providerResult = $channel->join($this->provider, $this->booking); + $this->assertIsArray($providerResult); + $this->assertSame('provider', $providerResult['user_type']); + } + + public function test_presence_channel_rejects_outsiders(): void + { + $channel = new BookingPresenceChannel(); + + $this->assertFalse($channel->join(User::factory()->create(['role' => 'member']), $this->booking)); + $this->assertFalse($channel->join(User::factory()->create(['role' => 'provider']), $this->booking)); + } + + public function test_presence_channel_rejects_non_confirmed_booking(): void + { + $this->booking->update(['status' => BookingStatus::Pending]); + $this->booking->refresh(); + + $channel = new BookingPresenceChannel(); + + $this->assertFalse($channel->join($this->member, $this->booking)); + } +} diff --git a/tests/Feature/BookingLifecycleTest.php b/tests/Feature/BookingLifecycleTest.php new file mode 100644 index 0000000..e25ff2b --- /dev/null +++ b/tests/Feature/BookingLifecycleTest.php @@ -0,0 +1,334 @@ +create(['role' => 'member']); + } + + private function makeProvider(): User + { + return User::factory()->create(['role' => 'provider']); + } + + private function makeOffer(User $provider): DivingOffer + { + return DivingOffer::create([ + 'provider_id' => $provider->id, + 'title' => 'Lifecycle Course', + 'location' => 'Test', + 'spot' => 'Test Spot', + 'price' => 1000, + 'region' => '南部', + 'rating' => 0, + 'reviews' => 0, + ]); + } + + private function makeSchedule(DivingOffer $offer, array $attributes = []): CourseSchedule + { + return CourseSchedule::create(array_merge([ + 'diving_offer_id' => $offer->id, + 'provider_id' => $offer->provider_id, + 'scheduled_date' => now()->addDays(7)->toDateString(), + 'start_time' => '09:00', + 'max_participants' => 4, + 'current_participants' => 0, + 'status' => ScheduleStatus::Open, + ], $attributes)); + } + + private function makeBooking(User $member, CourseSchedule $schedule, BookingStatus $status, int $participants = 1): Booking + { + return Booking::create([ + 'schedule_id' => $schedule->id, + 'member_id' => $member->id, + 'participants' => $participants, + 'total_price' => 1000 * $participants, + 'status' => $status, + ]); + } + + // ── 建立預約(pending) ────────────────────────────────── + + public function test_member_creates_pending_booking_without_occupying_spots(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider)); + $member = $this->makeMember(); + + $response = $this->actingAs($member)->postJson('/api/member/bookings', [ + 'schedule_id' => $schedule->id, + 'participants' => 2, + ]); + + $response->assertStatus(201)->assertJsonPath('data.status', 'pending'); + // 價格快照 = 單價 × 人數 + $response->assertJsonPath('data.total_price', 2000); + // pending 不佔名額 + $this->assertSame(0, $schedule->fresh()->current_participants); + } + + public function test_member_cannot_book_same_schedule_twice(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider)); + $member = $this->makeMember(); + $this->makeBooking($member, $schedule, BookingStatus::Pending); + + $this->actingAs($member)->postJson('/api/member/bookings', [ + 'schedule_id' => $schedule->id, + 'participants' => 1, + ])->assertStatus(422); + } + + public function test_booking_rejected_when_participants_exceed_remaining_spots(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider), [ + 'max_participants' => 4, + 'current_participants' => 3, + ]); + + $this->actingAs($this->makeMember())->postJson('/api/member/bookings', [ + 'schedule_id' => $schedule->id, + 'participants' => 2, + ])->assertStatus(422); + } + + public function test_booking_rejected_on_non_open_schedule(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider), [ + 'status' => ScheduleStatus::Cancelled, + ]); + + $this->actingAs($this->makeMember())->postJson('/api/member/bookings', [ + 'schedule_id' => $schedule->id, + 'participants' => 1, + ])->assertStatus(422); + } + + // ── Provider 確認 / 拒絕 ───────────────────────────────── + + public function test_provider_confirms_pending_booking_and_occupies_spots(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider)); + $booking = $this->makeBooking($this->makeMember(), $schedule, BookingStatus::Pending, 2); + + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$booking->id}/confirm") + ->assertOk(); + + $this->assertSame(BookingStatus::Confirmed, $booking->fresh()->status); + $this->assertSame(2, $schedule->fresh()->current_participants); + } + + public function test_confirming_last_spots_marks_schedule_full(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider), ['max_participants' => 2]); + $booking = $this->makeBooking($this->makeMember(), $schedule, BookingStatus::Pending, 2); + + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$booking->id}/confirm") + ->assertOk(); + + $this->assertSame(ScheduleStatus::Full, $schedule->fresh()->status); + } + + public function test_provider_cannot_confirm_rejected_booking(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider)); + $booking = $this->makeBooking($this->makeMember(), $schedule, BookingStatus::Rejected); + + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$booking->id}/confirm") + ->assertStatus(422); + + $this->assertSame(BookingStatus::Rejected, $booking->fresh()->status); + } + + public function test_provider_rejects_pending_booking(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider)); + $booking = $this->makeBooking($this->makeMember(), $schedule, BookingStatus::Pending); + + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$booking->id}/reject") + ->assertOk(); + + $this->assertSame(BookingStatus::Rejected, $booking->fresh()->status); + // 從未佔用名額,拒絕後也不應變動 + $this->assertSame(0, $schedule->fresh()->current_participants); + } + + // ── 完成 ───────────────────────────────────────────────── + + public function test_provider_completes_confirmed_booking(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider), ['current_participants' => 1]); + $booking = $this->makeBooking($this->makeMember(), $schedule, BookingStatus::Confirmed); + + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$booking->id}/complete") + ->assertOk(); + + $this->assertSame(BookingStatus::Completed, $booking->fresh()->status); + } + + public function test_provider_cannot_complete_pending_booking(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider)); + $booking = $this->makeBooking($this->makeMember(), $schedule, BookingStatus::Pending); + + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$booking->id}/complete") + ->assertStatus(422); + } + + // ── 取消與名額釋放 ─────────────────────────────────────── + + public function test_provider_cancel_releases_spots_and_reopens_full_schedule(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider), [ + 'max_participants' => 2, + 'current_participants' => 2, + 'status' => ScheduleStatus::Full, + ]); + $booking = $this->makeBooking($this->makeMember(), $schedule, BookingStatus::Confirmed, 2); + + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$booking->id}/cancel") + ->assertOk(); + + $schedule->refresh(); + $this->assertSame(BookingStatus::ProviderCancelled, $booking->fresh()->status); + $this->assertSame(0, $schedule->current_participants); + $this->assertSame(ScheduleStatus::Open, $schedule->status); + } + + public function test_member_cancels_pending_booking(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider)); + $member = $this->makeMember(); + $booking = $this->makeBooking($member, $schedule, BookingStatus::Pending); + + $this->actingAs($member) + ->deleteJson("/api/member/bookings/{$booking->id}") + ->assertOk(); + + $this->assertSame(BookingStatus::MemberCancelled, $booking->fresh()->status); + } + + public function test_member_cancel_confirmed_booking_releases_spots(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider), ['current_participants' => 2]); + $member = $this->makeMember(); + $booking = $this->makeBooking($member, $schedule, BookingStatus::Confirmed, 2); + + $this->actingAs($member) + ->deleteJson("/api/member/bookings/{$booking->id}") + ->assertOk(); + + $this->assertSame(BookingStatus::MemberCancelled, $booking->fresh()->status); + $this->assertSame(0, $schedule->fresh()->current_participants); + } + + public function test_member_cannot_cancel_within_24_hours_of_course_start(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider), [ + 'scheduled_date' => now()->addHours(5)->toDateString(), + 'start_time' => now()->addHours(5)->format('H:i'), + ]); + $member = $this->makeMember(); + $booking = $this->makeBooking($member, $schedule, BookingStatus::Confirmed); + + $this->actingAs($member) + ->deleteJson("/api/member/bookings/{$booking->id}") + ->assertStatus(422); + + $this->assertSame(BookingStatus::Confirmed, $booking->fresh()->status); + } + + public function test_member_cannot_cancel_completed_booking(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider)); + $member = $this->makeMember(); + $booking = $this->makeBooking($member, $schedule, BookingStatus::Completed); + + $this->actingAs($member) + ->deleteJson("/api/member/bookings/{$booking->id}") + ->assertStatus(422); + } + + // ── 授權邊界 ───────────────────────────────────────────── + + public function test_other_provider_cannot_operate_booking(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider)); + $booking = $this->makeBooking($this->makeMember(), $schedule, BookingStatus::Pending); + + $this->actingAs($this->makeProvider()) + ->putJson("/api/provider/bookings/{$booking->id}/confirm") + ->assertStatus(403); + } + + public function test_other_member_cannot_view_or_cancel_booking(): void + { + $provider = $this->makeProvider(); + $schedule = $this->makeSchedule($this->makeOffer($provider)); + $booking = $this->makeBooking($this->makeMember(), $schedule, BookingStatus::Pending); + $intruder = $this->makeMember(); + + $this->actingAs($intruder) + ->getJson("/api/member/bookings/{$booking->id}") + ->assertStatus(403); + + $this->actingAs($intruder) + ->deleteJson("/api/member/bookings/{$booking->id}") + ->assertStatus(403); + } +} diff --git a/tests/Feature/BookingOversellTest.php b/tests/Feature/BookingOversellTest.php new file mode 100644 index 0000000..418e9d6 --- /dev/null +++ b/tests/Feature/BookingOversellTest.php @@ -0,0 +1,143 @@ +create(['role' => 'provider']); + + $offer = DivingOffer::create([ + 'provider_id' => $provider->id, + 'title' => 'Oversell Course', + 'location' => 'Test', + 'spot' => 'Test Spot', + 'price' => 1000, + 'region' => '南部', + 'rating' => 0, + 'reviews' => 0, + ]); + + $schedule = CourseSchedule::create([ + 'diving_offer_id' => $offer->id, + 'provider_id' => $provider->id, + 'scheduled_date' => now()->addDays(7)->toDateString(), + 'start_time' => '09:00', + 'max_participants' => $maxParticipants, + 'current_participants' => 0, + 'status' => ScheduleStatus::Open, + ]); + + return [$provider, $schedule]; + } + + private function makePendingBooking(CourseSchedule $schedule, int $participants): Booking + { + return Booking::create([ + 'schedule_id' => $schedule->id, + 'member_id' => User::factory()->create(['role' => 'member'])->id, + 'participants' => $participants, + 'total_price' => 1000 * $participants, + 'status' => BookingStatus::Pending, + ]); + } + + public function test_multiple_pendings_may_exceed_capacity_but_confirm_is_gated(): void + { + [$provider, $schedule] = $this->makeProviderWithSchedule(2); + + // pending 總和 3 > 容量 2,allowed by design + $first = $this->makePendingBooking($schedule, 2); + $second = $this->makePendingBooking($schedule, 1); + + // 確認第一筆:佔滿容量 + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$first->id}/confirm") + ->assertOk(); + + // 確認第二筆:名額不足,必須失敗 + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$second->id}/confirm") + ->assertStatus(422); + + $schedule->refresh(); + $this->assertSame(2, $schedule->current_participants); + $this->assertSame(BookingStatus::Pending, $second->fresh()->status); + + // 不變式:confirmed 總人數 <= 容量 + $confirmedTotal = Booking::where('schedule_id', $schedule->id) + ->where('status', BookingStatus::Confirmed->value) + ->sum('participants'); + $this->assertLessThanOrEqual($schedule->max_participants, $confirmedTotal); + } + + public function test_full_schedule_rejects_new_booking_requests(): void + { + [$provider, $schedule] = $this->makeProviderWithSchedule(2); + $booking = $this->makePendingBooking($schedule, 2); + + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$booking->id}/confirm") + ->assertOk(); + + // 滿員後時段轉 full,新預約在 Layer 1 即被擋下 + $this->assertSame(ScheduleStatus::Full, $schedule->fresh()->status); + + $this->actingAs(User::factory()->create(['role' => 'member'])) + ->postJson('/api/member/bookings', [ + 'schedule_id' => $schedule->id, + 'participants' => 1, + ])->assertStatus(422); + } + + public function test_released_spots_can_be_confirmed_again(): void + { + [$provider, $schedule] = $this->makeProviderWithSchedule(2); + $first = $this->makePendingBooking($schedule, 2); + $second = $this->makePendingBooking($schedule, 2); + + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$first->id}/confirm") + ->assertOk(); + + // 教練取消第一筆 → 名額釋放、full 轉回 open + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$first->id}/cancel") + ->assertOk(); + + // 釋放後第二筆可確認 + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$second->id}/confirm") + ->assertOk(); + + $schedule->refresh(); + $this->assertSame(2, $schedule->current_participants); + $this->assertSame(BookingStatus::Confirmed, $second->fresh()->status); + } +} diff --git a/tests/Feature/BookingSchedulerTest.php b/tests/Feature/BookingSchedulerTest.php new file mode 100644 index 0000000..e4fbbae --- /dev/null +++ b/tests/Feature/BookingSchedulerTest.php @@ -0,0 +1,134 @@ +create(['role' => 'provider']); + + $offer = DivingOffer::create([ + 'provider_id' => $provider->id, + 'title' => 'Scheduler Course', + 'location' => 'Test', + 'spot' => 'Test Spot', + 'price' => 1000, + 'region' => '南部', + 'rating' => 0, + 'reviews' => 0, + ]); + + $schedule = CourseSchedule::create([ + 'diving_offer_id' => $offer->id, + 'provider_id' => $provider->id, + 'scheduled_date' => $scheduledDate, + 'start_time' => '09:00', + 'max_participants' => 4, + 'current_participants' => 0, + 'status' => ScheduleStatus::Open, + ]); + + return Booking::create([ + 'schedule_id' => $schedule->id, + 'member_id' => User::factory()->create(['role' => 'member'])->id, + 'participants' => 1, + 'total_price' => 1000, + 'status' => $status, + ]); + } + + private function backdateBooking(Booking $booking, int $hours): void + { + $booking->created_at = now()->subHours($hours); + $booking->save(); + } + + // ── app:expire-pending-bookings ────────────────────────── + + public function test_pending_older_than_48_hours_is_expired(): void + { + $booking = $this->makeBookingOnDate(BookingStatus::Pending, now()->addDays(7)->toDateString()); + $this->backdateBooking($booking, 49); + + $this->artisan('app:expire-pending-bookings')->assertSuccessful(); + + $this->assertSame(BookingStatus::Expired, $booking->fresh()->status); + } + + public function test_pending_within_48_hours_is_not_expired(): void + { + $booking = $this->makeBookingOnDate(BookingStatus::Pending, now()->addDays(7)->toDateString()); + $this->backdateBooking($booking, 47); + + $this->artisan('app:expire-pending-bookings')->assertSuccessful(); + + $this->assertSame(BookingStatus::Pending, $booking->fresh()->status); + } + + public function test_expire_command_does_not_touch_confirmed_bookings(): void + { + $booking = $this->makeBookingOnDate(BookingStatus::Confirmed, now()->addDays(7)->toDateString()); + $this->backdateBooking($booking, 100); + + $this->artisan('app:expire-pending-bookings')->assertSuccessful(); + + $this->assertSame(BookingStatus::Confirmed, $booking->fresh()->status); + } + + // ── app:complete-finished-bookings ─────────────────────── + + public function test_confirmed_booking_with_past_date_is_completed_and_member_notified(): void + { + $booking = $this->makeBookingOnDate(BookingStatus::Confirmed, now()->subDay()->toDateString()); + + $this->artisan('app:complete-finished-bookings')->assertSuccessful(); + + $this->assertSame(BookingStatus::Completed, $booking->fresh()->status); + Notification::assertSentTo($booking->member, BookingCompletedNotification::class); + } + + public function test_confirmed_booking_today_is_not_completed_yet(): void + { + $booking = $this->makeBookingOnDate(BookingStatus::Confirmed, now()->toDateString()); + + $this->artisan('app:complete-finished-bookings')->assertSuccessful(); + + $this->assertSame(BookingStatus::Confirmed, $booking->fresh()->status); + } + + public function test_complete_command_does_not_touch_pending_bookings(): void + { + // pending 即使課程日期已過也不應被標記完成(應由 expire 流程處理) + $booking = $this->makeBookingOnDate(BookingStatus::Pending, now()->subDay()->toDateString()); + + $this->artisan('app:complete-finished-bookings')->assertSuccessful(); + + $this->assertSame(BookingStatus::Pending, $booking->fresh()->status); + } +} From d3a0d300c62432312fc980b115469609571a7e35 Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 11 Jun 2026 17:49:00 +0800 Subject: [PATCH 5/7] =?UTF-8?q?docs(openspec):=20=E8=A6=8F=E6=A0=BC?= =?UTF-8?q?=E6=B8=85=E7=90=86=E2=80=94=E2=80=94=E8=A3=9C=20Purpose?= =?UTF-8?q?=E3=80=81=E4=BF=AE=E6=AD=A3=E5=89=8D=E7=AB=AF=20repo=20?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0=E3=80=81=E8=A3=9C=20admin=20=E6=9F=A5?= =?UTF-8?q?=E8=A9=A2=E7=AB=AF=E9=BB=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth-test-coverage:補歸檔後遺留的 TBD Purpose - member-portal-ui:前端實際位於本 repo frontend/,修正獨立 repo 描述 - admin-auth:補 check-member / check-provider 端點規格 Co-Authored-By: Claude Fable 5 --- openspec/specs/admin-auth/spec.md | 13 +++++++++++++ openspec/specs/auth-test-coverage/spec.md | 2 +- openspec/specs/member-portal-ui/spec.md | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/openspec/specs/admin-auth/spec.md b/openspec/specs/admin-auth/spec.md index 8151cb1..1e833c3 100644 --- a/openspec/specs/admin-auth/spec.md +++ b/openspec/specs/admin-auth/spec.md @@ -52,6 +52,19 @@ --- +### Requirement: 管理員查詢指定用戶資料 +後端 SHALL 提供 `GET /api/admin/check-member/{id}` 與 `GET /api/admin/check-provider/{id}`(需 Bearer token,role=admin),依角色查詢指定用戶的基本資料與對應 profile。 + +#### Scenario: 查詢存在的用戶 +- **WHEN** 管理員以有效 id 查詢對應角色的用戶 +- **THEN** 回傳 HTTP 200 與該用戶資料 + +#### Scenario: id 不存在或角色不符 +- **WHEN** 查詢的 id 不存在,或該用戶角色與端點不符(如以 check-member 查 provider) +- **THEN** 回傳 HTTP 404 + +--- + ### Requirement: 管理員 Bearer Token 有效期 後端 SHALL 發行有效期為 7 天的管理員 Bearer Token。主動使用 API 的 session 可透過 refresh 端點取得新 token(sliding window);閒置超過 7 天後需重新登入。 diff --git a/openspec/specs/auth-test-coverage/spec.md b/openspec/specs/auth-test-coverage/spec.md index ca8ae36..ed43547 100644 --- a/openspec/specs/auth-test-coverage/spec.md +++ b/openspec/specs/auth-test-coverage/spec.md @@ -1,7 +1,7 @@ # auth-test-coverage Specification ## Purpose -TBD - created by archiving change auth-tests. Update Purpose after archive. +驗證三角色(member / provider / admin)認證流程的測試覆蓋契約:登入/註冊/登出、帳號鎖定(P2)、OAuth state 驗證、token refresh 與登入頻率限制。此規格定義測試套件必須覆蓋的場景,任何認證行為變更時,對應測試必須同步失敗以攔截回歸。 ## Requirements ### Requirement: Auth 登入/註冊/登出測試覆蓋(三角色) 測試套件 SHALL 對 member、provider、admin 三個角色各自驗證以下場景,確保回歸時能即時偵測。 diff --git a/openspec/specs/member-portal-ui/spec.md b/openspec/specs/member-portal-ui/spec.md index c11dfc5..05644d3 100644 --- a/openspec/specs/member-portal-ui/spec.md +++ b/openspec/specs/member-portal-ui/spec.md @@ -1,7 +1,7 @@ ## ADDED Requirements ### Requirement: 專案基礎建設 -前端 SHALL 建立於獨立 repo,使用 Vue 3 + Vite + Tailwind CSS + Vue Router 4 + Pinia + Axios,並設定 `.env` 指定後端 API base URL。 +前端 SHALL 建立於本 repo 的 `frontend/` 目錄(原規劃獨立 repo,後併入主 repo 以簡化版控與部署),使用 Vue 3 + Vite + Tailwind CSS + Vue Router 4 + Pinia + Axios,並設定 `.env` 指定後端 API base URL。 #### Scenario: 開發環境啟動 - **WHEN** 開發者執行 `npm run dev` From cec20780351c0677a7b79db906656d70d4adedec Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 11 Jun 2026 17:52:37 +0800 Subject: [PATCH 6/7] =?UTF-8?q?test(auth):=20=E5=90=8C=E6=AD=A5=20admin=20?= =?UTF-8?q?register=20=E7=AB=AF=E9=BB=9E=E7=A7=BB=E9=99=A4=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=88=AA=E9=99=A4=E9=81=8E=E6=99=82=E6=B8=AC=E8=A9=A6?= =?UTF-8?q?=E3=80=81=E6=9B=B4=E6=96=B0=E8=A6=86=E8=93=8B=E8=A6=8F=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原 test_admin_register_* 測的是已移除的 P0 漏洞端點, 端點關閉防護與帳號建立改由 AdminAccountCreationTest 覆蓋。 容器內全套件 146 passed / 378 assertions。 Co-Authored-By: Claude Fable 5 --- openspec/specs/auth-test-coverage/spec.md | 10 ++----- tests/Feature/AuthLoginTest.php | 35 +---------------------- 2 files changed, 4 insertions(+), 41 deletions(-) diff --git a/openspec/specs/auth-test-coverage/spec.md b/openspec/specs/auth-test-coverage/spec.md index ed43547..da64c33 100644 --- a/openspec/specs/auth-test-coverage/spec.md +++ b/openspec/specs/auth-test-coverage/spec.md @@ -66,13 +66,9 @@ - **WHEN** role=member 的帳號嘗試 `POST /api/provider/login` - **THEN** 回傳 HTTP 401,`{ status: false, message: "電子郵件或密碼錯誤" }`(查詢以 role 過濾,跨角色帳號視同不存在) -#### Scenario: Admin 註冊成功 -- **WHEN** 送出有效的 name / email / password / password_confirmation 至 `POST /api/admin/register` -- **THEN** 回傳 HTTP 201,body 包含 `{ status: true, data: { user } }`,DB 存在對應 admin 用戶 - -#### Scenario: Admin 重複 Email 註冊失敗 -- **WHEN** 送出已存在的 email 至 `POST /api/admin/register` -- **THEN** 回傳 HTTP 422 +#### Scenario: Admin 公開註冊端點保持關閉 +- **WHEN** 任何人送出 `POST /api/admin/register`(該端點已於 2026-06-11 因 P0 漏洞移除,見 admin-auth 規格「管理員帳號建立途徑」) +- **THEN** 回傳 HTTP 404,且不建立任何帳號;帳號建立改由 `app:create-admin` command 覆蓋測試 #### Scenario: Admin 登入成功 - **WHEN** 送出正確的 email / password 至 `POST /api/admin/login` diff --git a/tests/Feature/AuthLoginTest.php b/tests/Feature/AuthLoginTest.php index a917473..5c9cacd 100644 --- a/tests/Feature/AuthLoginTest.php +++ b/tests/Feature/AuthLoginTest.php @@ -248,40 +248,7 @@ class AuthLoginTest extends TestCase ->assertStatus(401); } - // ── admin 註冊 ─────────────────────────────────────────── - - public function test_admin_register_success(): void - { - $response = $this->postJson('/api/admin/register', [ - 'name' => 'Test Admin', - 'email' => 'newadmin@example.com', - 'password' => 'password123', - 'password_confirmation' => 'password123', - ]); - - $response->assertStatus(201) - ->assertJsonPath('status', true) - ->assertJsonStructure(['data' => ['user']]); - - $this->assertDatabaseHas('users', [ - 'email' => 'newadmin@example.com', - 'role' => 'admin', - ]); - } - - public function test_admin_register_duplicate_email_returns_422(): void - { - $this->createAdmin(['email' => 'dup-admin@example.com']); - - $response = $this->postJson('/api/admin/register', [ - 'name' => 'Another Admin', - 'email' => 'dup-admin@example.com', - 'password' => 'password123', - 'password_confirmation' => 'password123', - ]); - - $response->assertStatus(422); - } + // admin 註冊端點已移除(P0 漏洞),帳號建立途徑見 AdminAccountCreationTest // ── admin 登入/登出 ───────────────────────────────────── From 7ca91da138a7dc145a0ef2d58061e195c2d7e634 Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 11 Jun 2026 17:55:56 +0800 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20=E6=93=8D=E4=BD=9C=E9=AB=94?= =?UTF-8?q?=E9=A9=97=E5=84=AA=E5=8C=96=E8=A8=88=E7=95=AB=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=AF=A6=E6=B8=AC=E5=AE=9A=E4=BD=8D=20OPcache=20=E5=81=9C?= =?UTF-8?q?=E7=94=A8=20+=20bind=20mount=20=E7=82=BA=E4=B8=BB=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 實測:API 快取命中仍 2.49s、容器測試比本機慢 14 倍; 容器內 opcache 已載入但 disabled。計畫分三階段,O1.1 啟用 OPcache 為最高投報率項目。 Co-Authored-By: Claude Fable 5 --- ...026-06-11-performance-optimization-plan.md | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 docs/analysis/2026-06-11-performance-optimization-plan.md diff --git a/docs/analysis/2026-06-11-performance-optimization-plan.md b/docs/analysis/2026-06-11-performance-optimization-plan.md new file mode 100644 index 0000000..9b2be70 --- /dev/null +++ b/docs/analysis/2026-06-11-performance-optimization-plan.md @@ -0,0 +1,99 @@ +# CFDivePlatform 操作體驗優化計畫 + +> 撰寫日期:2026-06-11 +> 背景:使用者反映本機操作體驗差、回應慢。本文以實測數據定位根因,並依投資報酬率排序優化項目。 + +--- + +## 一、實測基準(2026-06-11,本機 Docker 環境) + +| 量測項目 | 數值 | 備註 | +|---------|------|------| +| `GET /api/diving-offers`(冷) | **7.98 秒** | 經 nginx:8080 → php-fpm | +| 同端點第 2 次 | 6.07 秒 | | +| 同端點第 3 次(Redis 快取命中) | **2.49 秒** | 快取命中仍要 2.5 秒 → 瓶頸不在查詢 | +| 容器內全測試套件 | 64.4 秒 | 同套件本機 PHP 僅 4.5 秒(**14 倍差距**) | + +**判讀**:Redis 快取命中後仍需 2.5 秒,代表時間幾乎都花在「PHP 啟動 + 載入框架程式碼」,而非業務邏輯或資料庫。瓶頸在環境層,不在程式碼。 + +## 二、根因分析(已驗證) + +1. **OPcache 已載入但被停用**(容器內實測 `opcache_get_status` 回報 disabled;`docker/php/local.ini` 無任何 opcache 設定)。每個 HTTP 請求都重新編譯 Laravel 全框架數千個 PHP 檔。 +2. **Windows bind mount `./:/var/www`**(docker-compose.yml:16)。每次檔案讀取都跨越 Windows↔Linux 檔案系統邊界(Docker Desktop 的檔案橋接是已知效能黑洞),與根因 1 疊加:每請求數千次跨界 I/O。 +3. 次要因素: + - `cfdive-nginx` healthcheck 持續失敗(`wget http://localhost/` connection refused),雖不影響服務但遮蔽真正的健康狀態。 + - `SESSION_DRIVER=database`、`QUEUE_CONNECTION=database`:queue worker 輪詢 MySQL;API 多為 stateless 影響小,列為觀察項。 + - `APP_DEBUG=true`:開發環境正常設定,影響相對小。 + +## 三、優化計畫 + +### Phase 1 — 立即(0.5 天內,預期把 API 壓到 0.5 秒以下) + +**O1.1 啟用 OPcache(投報率最高的一項)** + +在 `docker/php/local.ini` 加入開發友善設定後 rebuild app 容器: + +```ini +opcache.enable=1 +opcache.enable_cli=0 +opcache.memory_consumption=192 +opcache.max_accelerated_files=20000 +; 開發環境:仍偵測檔案變更,存檔即生效 +opcache.validate_timestamps=1 +opcache.revalidate_freq=0 +``` + +驗收:`GET /api/diving-offers` 快取命中請求 < 500ms(對照基準 2.49s)。 + +**O1.2 修復 nginx healthcheck** + +診斷 `wget http://localhost/` 為何 connection refused(常見原因:nginx 只 listen 特定 server_name、或 healthcheck 應改打 `127.0.0.1`),修正 docker-compose.yml:63 的 healthcheck。驗收:`docker compose ps` 顯示 nginx healthy。 + +**O1.3 `composer dump-autoload -o`** + +寫入 Dockerfile / entrypoint,減少 autoload 時的檔案系統探測。 + +### Phase 2 — 結構性(若 Phase 1 後仍 > 1 秒才執行,0.5~1 天) + +**O2.1 消除 Windows bind mount I/O** + +選項(擇一,建議 a 先試): +- (a) **vendor/ 改用 named volume**:`- vendor-data:/var/www/vendor`,框架程式碼(I/O 大宗)留在 Linux 原生檔案系統,原始碼仍可即時編輯。需在容器內跑 `composer install`。 +- (b) **整個 repo 搬入 WSL2 檔案系統**(`\\wsl$\Ubuntu\...`),IDE 透過 WSL remote 開啟。I/O 改善最徹底(10~50 倍),但改變現有 laragon 工作流程,遷移成本較高。 + +**O2.2 SESSION/QUEUE driver 評估** + +Redis 已在跑,`SESSION_DRIVER=redis`、`QUEUE_CONNECTION=redis` 可移除 queue worker 對 MySQL 的輪詢壓力。屬順手改,不是主瓶頸。 + +### Phase 3 — 應用層體感(與環境無關,部署後使用者也受益,1~2 天) + +**O3.1 課程封面圖未壓縮** + +聊天圖片已有 `scaleDown(2048) + toJpeg(85)` 處理(`BookingMessageController.php:115`),但 `CourseImageController` 上傳的封面/圖庫**原檔直存**。手機拍攝的 5~10MB 原圖會直接進課程列表頁。建議:套用與聊天圖片相同的縮圖管線,列表用圖另出 ~600px 縮圖;前端 `` 加 `loading="lazy"`。 + +**O3.2 無分頁的列表端點** + +`GET /api/member/bookings`、`/api/provider/bookings`、`/api/admin/bookings` 皆為 `->get()` 全量撈取。目前資料量小無感,預約數成長後會線性變慢。建議在金流上線前(資料開始累積)補分頁。 + +**O3.3 前端載入體感** + +- 路由已全面 lazy-load(已驗證,不需處理)。 +- 列表頁加 skeleton 載入佔位,消除「白屏等待」體感。 +- 搜尋輸入加 debounce(300ms),減少連發請求。 + +### 生產環境註記(部署時順手做) + +- `php artisan optimize`(config / route / view cache) +- 生產 OPcache 改 `validate_timestamps=0`(部署時重啟 php-fpm 刷新) + +## 四、執行順序與驗收 + +``` +第 1 步 O1.1 OPcache → 重測基準(預期 2.49s → <0.5s) +第 2 步 O1.2 + O1.3 順手修 +第 3 步 視重測結果決定是否做 O2.1(>1s 才做) +第 4 步 O3.1 圖片壓縮(開 openspec change,影響 course-image-upload 規格) +第 5 步 O3.2 / O3.3 排入一般 backlog +``` + +每步驗收一律以第一節的 curl 量測方式對照,數據寫回本文件。