security(openspec): 新增 add-role-middleware change 規劃
Run Tests / test (pull_request) Successful in 36s

待辦 H1:member/provider 路由群組補上角色 middleware。
新增 role-authorization 規格,並修改 feature-test-coverage 的
「預約列表端點測試覆蓋」。spec-reviewer 與 design-hardener 均 PASS。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 23:28:06 +08:00
parent 1839e9008f
commit 6feb79bc1a
6 changed files with 244 additions and 0 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-17
@@ -0,0 +1,70 @@
# Design
## Context
動機見 proposal.md(Why)。目前現況:
- `bootstrap/app.php` 只註冊一個角色 middleware alias:`admin` → `App\Http\Middleware\EnsureAdmin`。它在 `!$request->user() || !$request->user()->isAdmin()` 時回 `403 { status: false, message: "無權限存取" }`
- `App\Models\User` 已有 `isMember()`、`isProvider()`、`isAdmin()`,都是直接比對 `role` 字串
- `/api/member/*`、`/api/provider/*` 群組內,只有 `AuthController` 的 logout/refresh/profile/change-password 自己檢查角色,回應同樣是 `403 { status: false, message: "無權限存取" }`;`MemberBookingController`、`ReviewController`(store/update/destroy)、`ProviderOfferController`、`ScheduleController`、`ProviderBookingController`、`CourseImageController`、`ProviderVerificationController` 只檢查資源所有權,不檢查角色
- 前端 `axios.js`、`coachAxios.js` 的攔截器只對 401 做 refresh 重試,403 直接交給呼叫端;`adminAxios.js` 沒有呼叫 `/member/*`、`/provider/*`
- 部署流程(`deploy.yml`)每次都會執行 `route:cache`,路由變更隨部署生效
## Goals / Non-Goals
**Goals:**
- 在路由層擋下所有跨角色呼叫 member/provider 專屬端點
- 角色不符時的回應與現有 `EnsureAdmin`、`AuthController` 完全一致,前端不需要改
**Non-Goals:**
- 不檢查 `is_active`(停用帳號沿用既有行為,與角色 middleware 無關)、教練審核狀態(審核狀態的限制維持在既有業務邏輯,例如 `visibleToPublic` scope)
- 不移除 `AuthController` 內既有的角色檢查(重構屬待辦 M1)
- 不調整跨角色共用路由(通知、即時訊息、`/reviews/{id}/helpful`、`/logout`、`/user`),包含「admin 能否存取這些路由」也不在此次決定
- 不更動 `EnsureAdmin`
## Decisions
### D1:新增 `EnsureMember`、`EnsureProvider` 兩個 middleware,而非參數化的 `EnsureRole`
- 做法:在 `app/Http/Middleware/` 新增兩個 class,結構與 `EnsureAdmin` 相同,分別呼叫 `isMember()`、`isProvider()`;在 `bootstrap/app.php` 註冊 alias `member`、`provider`
- 理由:既有慣例是每個角色一個 middleware(`EnsureAdmin` + alias `admin`)。若新增參數化的 `role:member`,專案內會同時存在兩種寫法;要統一就得改 `EnsureAdmin` 的使用處,超出此次範圍
- 替代方案:`EnsureRole` 搭配 `role:member,provider`。程式碼較少,但會跟既有的 `admin` alias 形成兩種寫法並存,因此不採用
### D2:403 回應沿用 `{ status: false, message: "無權限存取" }`
- 理由:與 `EnsureAdmin`、`AuthController::getUnauthorizedResponse()` 一致,前端與 Swagger 已記載的 403 不需變動
- middleware 內同時處理 `$request->user()` 為 null 的情況(與 `EnsureAdmin` 相同的防禦寫法);實際上 `auth:sanctum` 排在前面,未登入會先回 401
### D3:middleware 只比對角色,不檢查審核狀態
- 理由:未審核(`unsubmitted`)、審核中(`pending`)、被駁回(`rejected`)的教練都需要進入 `/api/provider/verification*` 送審或補件,也需要在審核通過前先建立課程。若在 middleware 檢查審核狀態,這些流程會整個斷掉
### D4:掛在既有群組的 middleware 陣列,順序為 `['auth:sanctum', 'member']`/`['auth:sanctum', 'provider']`
- 理由:與 admin 群組 `['auth:sanctum', 'admin']` 寫法一致;`auth:sanctum` 先執行,保證未登入回 401、角色不符回 403
- 公開的 register/login 路由定義在群組外,不受影響(已核對 `routes/api.php`)
### D5:Controller 內既有的資源所有權檢查全部保留
- 理由:CLAUDE.md 授權規範要求 middleware 補上後仍保留 Controller 內的所有權檢查;角色 middleware 只解決「角色錯」,不解決「同角色存取別人的資源」
## 安全確認
- **輸入驗證**:此次不新增外部輸入;既有端點的 `$request->validate()` 不變。middleware 只讀取 Sanctum 認證後的 `$request->user()->role`,沒有讀取任何請求參數
- **SQL/shell 拼接**:無
- **資料庫存取**:middleware 不額外查詢資料庫(`$request->user()` 由 Sanctum 認證時已載入);無 raw query,無 N+1
- **權限檢查層級**:路由層(`member`、`provider` middleware),加上 Controller 內既有的資源所有權檢查(例如 `MemberBookingController::show` 比對 `member_id`、`ProviderOfferController` 比對 `provider_id`)
- **未授權觸發可能性**:修正後,非 member 角色無法存取 `/api/member/*`,非 provider 角色無法存取 `/api/provider/*`;未登入一律 401
- **各路由所掛 middleware**:
- `/api/member/*`(register、login 除外):`auth:sanctum` + `member`
- `/api/provider/*`(register、login 除外):`auth:sanctum` + `provider`
- 其他路由不變
- **前端**:不涉及 DOM 渲染;前端呼叫已帶各自的 Bearer token,不需改
## Risks / Trade-offs
- [既有測試可能以錯誤角色呼叫 member/provider 路由,加上 middleware 後改回 403] → 實作時先跑完整 `php artisan test`,逐一判斷失敗的測試是「測試本身用錯角色」(修正測試)還是「真的依賴跨角色行為」(停下來回報)
- [AuthController 內的角色檢查與 middleware 重複] → 行為一致、無害;移除留給待辦 M1,避免此次範圍擴大
- [admin 被擋在 member/provider 路由之外,若未來後台想借用這些端點會失敗] → 已核對前端 admin 端目前沒有呼叫;未來需要時應在 `/api/admin/*` 新增端點
- [跨角色共用路由(通知、即時訊息)對 admin 的行為未定義] → 不在此次範圍;即時訊息的 `authorizeParticipant` 對 admin 已回 false,通知只會讀到 admin 自己的資料
## Migration Plan
- 無 migration、無新套件、無環境變數
- 部署:merge 後由 `deploy.yml` 重建 image,並執行 `route:cache`,新的路由 middleware 即生效
- 回滾:revert 此次 commit 再部署即可,沒有資料需要還原
@@ -0,0 +1,32 @@
# Proposal
## Why
`routes/api.php` 的 `member`、`provider` 路由群組只掛 `auth:sanctum`,沒有角色檢查(待辦 H1,見 `docs/analysis/2026-10-14-project-review-todo.md`)。任何已登入的帳號都能跨角色呼叫:member 或 admin 可呼叫 `POST /api/provider/offers` 建立課程(`provider_id` 會被設成呼叫者),provider 或 admin 可呼叫 `POST /api/member/bookings` 建立預約。這兩個群組內只有 `AuthController` 的帳號類端點自己檢查角色,其餘業務端點都沒有防護。
## What Changes
- 新增 member 角色 middleware 與 provider 角色 middleware,比照既有 `admin` middleware 的行為:角色不符時回 `403 { status: false, message: "無權限存取" }`
- 將它們分別掛到 `/api/member/*` 與 `/api/provider/*` 路由群組;未登入仍由 `auth:sanctum` 先回 401
- provider middleware **只檢查角色,不檢查審核狀態**:未審核或審核中的教練仍需能進入 `/api/provider/*`(例如送審證照);審核狀態的限制維持在既有的業務邏輯
- admin 也會被擋在 `/api/member/*` 與 `/api/provider/*` 之外(前端 admin 端目前沒有呼叫這兩組路由,已核對 `frontend/src`)
- **BREAKING(僅對越權呼叫)**:原本能成功的跨角色呼叫(例如 member 呼叫 `POST /api/provider/offers`)改為 403。合法角色的行為與回應格式不變
- 不在範圍內:通知(`/api/notifications/*`)、即時訊息(`/api/bookings/{booking}/messages*`)、`POST /api/reviews/{id}/helpful`、`/api/logout`、`/api/user` 等跨角色共用路由維持現狀;`AuthController` 內既有的重複角色檢查不在此次移除(屬待辦 M1)
## Capabilities
### New Capabilities
- `role-authorization`:依使用者角色限制 member、provider 專屬 API 路由群組的存取(各角色與未登入的預期結果)
### Modified Capabilities
- `feature-test-coverage`:「預約列表端點測試覆蓋」Requirement 內文描述 member/provider 預約列表的 middleware 只有 `auth:sanctum`,需改為反映新增的角色 middleware
## Impact
- 程式碼:`app/Http/Middleware/`(新增兩個 middleware)、`bootstrap/app.php`(註冊 alias)、`routes/api.php`(兩個群組加 middleware)
- 測試:新增跨角色 Feature 測試(參考 `tests/Feature/AdminEndpointAuthTest.php`);既有測試若依賴跨角色呼叫需檢視
- API:回應格式不變;跨角色呼叫從原本的 2xx/422 改為 403
- 前端:不需修改(三組前端各自只呼叫自己角色的路由)
- Swagger:`app/Docs/MemberApiDoc.php`、`ProviderApiDoc.php` 已有 403 回應,只需核對描述是否涵蓋「角色不符」
- 不影響主規格:`provider-auth`、`admin-auth`(其 Requirement 未描述路由群組的角色限制,行為也不變)
- 部署:無 migration、無新相依套件、無環境變數
@@ -0,0 +1,25 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: 預約列表端點測試覆蓋
測試套件 SHALL 驗證下列三個路由的資料隔離行為:
- `GET /api/member/bookings`(middleware: `auth:sanctum` + `member`,非 member 角色於 middleware 層強制 403;隔離靠 `where member_id = auth()->id()`)
- `GET /api/provider/bookings`(middleware: `auth:sanctum` + `provider`,非 provider 角色於 middleware 層強制 403;隔離靠 `whereHas schedule.provider_id`)
- `GET /api/admin/bookings`(middleware: `auth:sanctum` + `admin`,EnsureAdmin 於 middleware 層強制 403)
#### Scenario: Member 只能看到自己的預約
- **WHEN** Member A 呼叫 `GET /api/member/bookings`,系統中同時存在 Member A 與 Member B 各一筆預約
- **THEN** 回傳 data 只包含 Member A 的 booking id;Member B 的 booking id 不在 data 中
#### Scenario: Provider 只能看到屬於自己課程的預約
- **WHEN** Provider A 呼叫 `GET /api/provider/bookings`,系統中有屬於 Provider A 與 Provider B 課程各一筆預約
- **THEN** 回傳 data 只包含 Provider A 課程的 booking id;Provider B 課程的 booking id 不在 data 中
#### Scenario: Admin 可查看所有預約
- **WHEN** Admin 呼叫 `GET /api/admin/bookings`,系統中有 2 筆不同 Member 的預約
- **THEN** 回傳 data 包含兩筆 booking id
#### Scenario: 未認證請求被拒絕
- **WHEN** 未帶 token 呼叫 `GET /api/member/bookings`
- **THEN** 回傳 HTTP 401
@@ -0,0 +1,68 @@
# Spec Delta
## Purpose
依使用者角色限制 member、provider 專屬 API 路由群組的存取,避免已登入的其他角色跨角色呼叫課程、時段、預約等業務端點。
## ADDED Requirements
### Requirement: Member 專屬路由僅限 member 角色存取
`/api/member/*` 路由群組(不含公開的 `POST /api/member/register`、`POST /api/member/login`)SHALL 只允許 `role = member` 的已登入使用者存取。其他角色 MUST 收到 HTTP 403,回應為 `{ "status": false, "message": "無權限存取" }`,且請求 MUST NOT 產生任何資料變更。未帶 token 的請求 MUST 收到 HTTP 401。
#### Scenario: member 可存取 member 路由
- **WHEN** 一個 `role = member` 的使用者,以該使用者的 token 呼叫 `GET /api/member/profile` 與 `GET /api/member/bookings`
- **THEN** 回傳 HTTP 200,`status` 為 `true`
#### Scenario: provider 呼叫 member 路由被拒絕
- **WHEN** 一個 `role = provider` 的使用者,以該使用者的 token 呼叫 `GET /api/member/profile`、`GET /api/member/bookings`、`POST /api/member/bookings`、`POST /api/member/reviews`
- **THEN** 每個請求都回傳 HTTP 403,body 為 `{ "status": false, "message": "無權限存取" }`
#### Scenario: admin 呼叫 member 路由被拒絕
- **WHEN** 一個 `role = admin` 的使用者,以該使用者的 token 呼叫 `GET /api/member/profile`、`GET /api/member/bookings`、`POST /api/member/bookings`
- **THEN** 每個請求都回傳 HTTP 403,body 為 `{ "status": false, "message": "無權限存取" }`
#### Scenario: 非 member 建立預約不會產生資料
- **WHEN** 一個可預約的課程時段,以及一個 `role = provider` 的使用者,以該使用者的 token 呼叫 `POST /api/member/bookings` 並帶入合法的時段 id
- **THEN** 回傳 HTTP 403,`bookings` 資料表筆數不變,該時段的 `current_participants` 不變
#### Scenario: 未登入呼叫 member 路由
- **WHEN** 不帶 token 呼叫 `GET /api/member/profile`、`GET /api/member/bookings`
- **THEN** 回傳 HTTP 401
### Requirement: Provider 專屬路由僅限 provider 角色存取
`/api/provider/*` 路由群組(不含公開的 `POST /api/provider/register`、`POST /api/provider/login`)SHALL 只允許 `role = provider` 的已登入使用者存取,且 MUST NOT 以教練審核狀態作為進入條件。其他角色 MUST 收到 HTTP 403,回應為 `{ "status": false, "message": "無權限存取" }`,且請求 MUST NOT 產生任何資料變更。未帶 token 的請求 MUST 收到 HTTP 401。
#### Scenario: provider 可存取 provider 路由
- **WHEN** 一個 `role = provider` 的使用者,以該使用者的 token 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`、`GET /api/provider/schedules`、`GET /api/provider/bookings`
- **THEN** 回傳 HTTP 200,`status` 為 `true`
#### Scenario: 未通過審核的 provider 仍可進入 provider 路由
- **WHEN** 一個 `role = provider`、`verification_status` 不是 approved(例如 unsubmitted 或 pending)的使用者,以該使用者的 token 呼叫 `GET /api/provider/verification`
- **THEN** 回傳 HTTP 200,不會因角色 middleware 回 403
#### Scenario: member 呼叫 provider 路由被拒絕
- **WHEN** 一個 `role = member` 的使用者,以該使用者的 token 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`、`POST /api/provider/offers`、`POST /api/provider/schedules`、`GET /api/provider/bookings`、`GET /api/provider/verification`
- **THEN** 每個請求都回傳 HTTP 403,body 為 `{ "status": false, "message": "無權限存取" }`
#### Scenario: admin 呼叫 provider 路由被拒絕
- **WHEN** 一個 `role = admin` 的使用者,以該使用者的 token 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`、`POST /api/provider/offers`、`GET /api/provider/bookings`
- **THEN** 每個請求都回傳 HTTP 403,body 為 `{ "status": false, "message": "無權限存取" }`
#### Scenario: 非 provider 建立課程不會產生資料
- **WHEN** 一個 `role = member` 的使用者,以該使用者的 token 呼叫 `POST /api/provider/offers` 並帶入通過驗證的完整課程資料
- **THEN** 回傳 HTTP 403,`diving_offers` 資料表筆數不變
#### Scenario: 未登入呼叫 provider 路由
- **WHEN** 不帶 token 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`
- **THEN** 回傳 HTTP 401
### Requirement: 跨角色共用路由不受角色限制影響
通知(`/api/notifications/*`)與預約即時訊息(`/api/bookings/{booking}/messages*`、`/api/bookings/messages/unread-counts`)路由 SHALL 維持既有行為:member 與 provider 皆可存取,存取權限由既有的資源歸屬檢查決定,不套用 member 或 provider 角色限制。
#### Scenario: member 與 provider 皆可讀取通知
- **WHEN** 一個 `role = member` 與一個 `role = provider` 的使用者,分別以各自的 token 呼叫 `GET /api/notifications`
- **THEN** 兩者皆回傳 HTTP 200
#### Scenario: member 與 provider 皆可讀取未讀訊息數
- **WHEN** 一個 `role = member` 與一個 `role = provider` 的使用者,分別以各自的 token 呼叫 `GET /api/bookings/messages/unread-counts`
- **THEN** 兩者皆回傳 HTTP 200
@@ -0,0 +1,47 @@
# Tasks
測試集中在新檔 `tests/Feature/RoleAuthorizationTest.php`(結構參考 `tests/Feature/AdminEndpointAuthTest.php`)。每個測試的 docblock 要寫明「為什麼」:跨角色呼叫曾可建立非教練課程、非會員預約。
## 1. Member 角色 middleware
- [ ] 1.1 [後端] 新增 `app/Http/Middleware/EnsureMember.php`(結構同 `EnsureAdmin`,改用 `isMember()`),並在 `bootstrap/app.php` 的 `$middleware->alias([...])` 加上 `'member' => \App\Http\Middleware\EnsureMember::class`;驗證:1.3 起的測試通過
- [ ] 1.2 [後端] `routes/api.php` 的 member 群組改為 `Route::middleware(['auth:sanctum', 'member'])->prefix('member')`;驗證:`php artisan route:list --path=api/member` 顯示 `member` middleware,且 `register`、`login` 不受影響
- [ ] 1.3 [整合測試] `test_member_can_access_member_routes`:member 呼叫 `GET /api/member/profile`、`GET /api/member/bookings` 回 200(對應 Scenario「member 可存取 member 路由」)
- [ ] 1.4 [整合測試] `test_provider_is_rejected_on_member_routes`:provider 呼叫 `GET /api/member/profile`、`GET /api/member/bookings`、`POST /api/member/bookings`、`POST /api/member/reviews`,皆回 403,並斷言 body 為 `{ status: false, message: "無權限存取" }`;`POST /api/member/reviews` 另斷言 `reviews` 筆數不變(對應 Scenario「provider 呼叫 member 路由被拒絕」)
- [ ] 1.5 [整合測試] `test_admin_is_rejected_on_member_routes`:admin 呼叫 `GET /api/member/profile`、`GET /api/member/bookings`、`POST /api/member/bookings`,皆回 403 與相同 body(對應 Scenario「admin 呼叫 member 路由被拒絕」)
- [ ] 1.6 [整合測試] `test_non_member_booking_attempt_creates_no_booking`:建立可預約時段,provider 帶合法 `schedule_id` 呼叫 `POST /api/member/bookings`,回 403,`bookings` 筆數與 `current_participants` 不變(對應 Scenario「非 member 建立預約不會產生資料」)
- [ ] 1.7 [整合測試] `test_unauthenticated_request_is_rejected_on_member_routes`:不帶 token 呼叫 `GET /api/member/profile`、`GET /api/member/bookings` 回 401(對應 Scenario「未登入呼叫 member 路由」)
## 2. Provider 角色 middleware
- [ ] 2.1 [後端] 新增 `app/Http/Middleware/EnsureProvider.php`(結構同 `EnsureAdmin`,改用 `isProvider()`,**不檢查** `verification_status`),並在 `bootstrap/app.php` 加上 `'provider' => \App\Http\Middleware\EnsureProvider::class`;驗證:2.3 起的測試通過
- [ ] 2.2 [後端] `routes/api.php` 的 provider 群組改為 `Route::middleware(['auth:sanctum', 'provider'])->prefix('provider')`;驗證:`php artisan route:list --path=api/provider` 顯示 `provider` middleware,且 `register`、`login` 不受影響
- [ ] 2.3 [整合測試] `test_provider_can_access_provider_routes`:provider(含 `providerProfile`)呼叫 `GET /api/provider/profile`、`/offers`、`/schedules`、`/bookings` 回 200(對應 Scenario「provider 可存取 provider 路由」)
- [ ] 2.4 [整合測試] `test_unapproved_provider_can_access_verification`:`verification_status` 為 `unsubmitted`、`pending`、`rejected` 的 provider 呼叫 `GET /api/provider/verification` 回 200(對應 Scenario「未通過審核的 provider 仍可進入 provider 路由」)
- [ ] 2.5 [整合測試] `test_member_is_rejected_on_provider_routes`:member 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`、`POST /api/provider/offers`、`POST /api/provider/schedules`、`GET /api/provider/bookings`、`GET /api/provider/verification`,皆回 403 與 `{ status: false, message: "無權限存取" }`;`POST /api/provider/schedules` 另斷言 `course_schedules` 筆數不變(對應 Scenario「member 呼叫 provider 路由被拒絕」)
- [ ] 2.6 [整合測試] `test_admin_is_rejected_on_provider_routes`:admin 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`、`POST /api/provider/offers`、`GET /api/provider/bookings`,皆回 403 與相同 body(對應 Scenario「admin 呼叫 provider 路由被拒絕」)
- [ ] 2.7 [整合測試] `test_non_provider_offer_attempt_creates_no_offer`:member 帶通過驗證的完整課程資料呼叫 `POST /api/provider/offers`,回 403,`diving_offers` 筆數不變(對應 Scenario「非 provider 建立課程不會產生資料」)
- [ ] 2.8 [整合測試] `test_unauthenticated_request_is_rejected_on_provider_routes`:不帶 token 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers` 回 401(對應 Scenario「未登入呼叫 provider 路由」)
## 3. 共用路由與既有規格的回歸
- [ ] 3.1 [整合測試] `test_member_and_provider_can_read_notifications`:member 與 provider 各自呼叫 `GET /api/notifications` 回 200(對應 Scenario「member 與 provider 皆可讀取通知」)
- [ ] 3.2 [整合測試] `test_member_and_provider_can_read_unread_message_counts`:member 與 provider 各自呼叫 `GET /api/bookings/messages/unread-counts` 回 200(對應 Scenario「member 與 provider 皆可讀取未讀訊息數」)
- [ ] 3.3 [整合測試] 執行 `php artisan test --filter=BookingListTest`,確認 `feature-test-coverage`「預約列表端點測試覆蓋」的四個 Scenario(member 隔離、provider 隔離、admin 全部、未認證 401)在新 middleware 下仍全綠(「非 member/非 provider 於 middleware 層 403」由 1.4、1.5、2.5、2.6 覆蓋);同時更新 `tests/Feature/BookingListTest.php` 第 19–24 行 docblock 中「無 role check」「不靠 middleware role 強制」的過時描述
## 4. 全套回歸、Swagger 與格式
- [ ] 4.1 [整合測試] 執行完整 `php artisan test`(需在容器內,因 `CourseImageTest` 需要 GD);若有既有測試因角色錯誤改回 403,判斷是測試用錯角色(修正測試並在 commit 說明)還是依賴跨角色行為(停下回報)
- [ ] 4.2 [後端] 核對 `app/Docs/MemberApiDoc.php`、`app/Docs/ProviderApiDoc.php` 每個端點的 403 回應描述涵蓋「角色不符」;缺的補上,並執行 `php artisan l5-swagger:generate` 確認無錯誤
- [ ] 4.3 [後端] 執行 `./vendor/bin/pint --test app/Http/Middleware/EnsureMember.php app/Http/Middleware/EnsureProvider.php bootstrap/app.php routes/api.php tests/Feature/RoleAuthorizationTest.php`(以及 4.1/4.2 若有修改的檔案),確認無格式問題
## 5. 手動驗證
- [ ] 5.1 [整合測試](手動,非自動化測試)本機 Docker 環境以 member token 呼叫 `POST /api/provider/offers`,以 provider token 呼叫 `POST /api/member/bookings`,確認兩者皆回 403
- [ ] 5.2 [整合測試](手動,非自動化測試)本機前端:會員端登入後瀏覽「我的預約」、教練端登入後瀏覽課程/時段/預約/審核頁,確認功能正常、沒有出現 403
## Workflow follow-up
- 實作完成後依 CLAUDE.md 執行 `hardener` skill,再呼叫 `strict-reviewer`
- 執行 `verify-and-report` skill 回報結果
- 實作與歸檔各自獨立 PR;歸檔時同步 `openspec/config.yaml` 後端慣例段落(移除「member/provider 路由群組目前僅有 `auth:sanctum`」的描述)、CLAUDE.md「授權規範」的已知缺口描述,以及 `docs/analysis/2026-10-14-project-review-todo.md` H1 的完成狀態