Compare commits

...

77 Commits

Author SHA1 Message Date
a620906209 d1fe3d4138 Merge pull request 'docs(readme): 服務 URL 表格補上生產環境對應網址' (#54) from feat/swagger-coverage-gaps into master
Deploy Production / deploy (push) Successful in 13s
Run Tests / test (push) Successful in 29s
Reviewed-on: #54
2026-06-23 08:05:36 +00:00
a620906209 0bffa909e2 docs(readme): 服務 URL 表格補上生產環境對應網址
Run Tests / test (pull_request) Successful in 32s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 16:03:11 +08:00
a620906209 9222bf17fc Merge pull request 'fix(swagger): 修正 VPS 無法顯示 Swagger UI 的問題' (#53) from feat/swagger-coverage-gaps into master
Deploy Production / deploy (push) Successful in 17s
Run Tests / test (push) Successful in 33s
Reviewed-on: #53
2026-06-23 07:27:32 +00:00
a620906209 b218fa0964 fix(swagger): 修正 VPS 無法顯示 Swagger UI 的問題
Run Tests / test (pull_request) Successful in 33s
問題根因:
1. deploy.yml 未執行 l5-swagger:generate,VPS 上不存在 api-docs.json
2. @OA\Server 使用相對路徑 "/api",Swagger UI Try it out 無法正確打到 VPS API

修正:
- @OA\Server 改用 L5_SWAGGER_CONST_HOST 常數,透過 .env 各環境設定
- deploy.yml 在 config:cache 後補上 l5-swagger:generate 步驟
- .env.example 補上 L5_SWAGGER_CONST_HOST 與 L5_SWAGGER_GENERATE_ALWAYS 說明

VPS .env 需手動加入:
L5_SWAGGER_CONST_HOST=https://api.hank-space.com/api

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 15:23:18 +08:00
a620906209 a7e8468613 Merge pull request 'docs(swagger): 補全缺漏端點標註 + 更新 README 文件 URL' (#52) from feat/swagger-coverage-gaps into master
Deploy Production / deploy (push) Successful in 15s
Run Tests / test (push) Successful in 31s
Reviewed-on: #52
2026-06-23 07:09:07 +00:00
a620906209 60ff56a571 docs(swagger): 補全缺漏端點標註 + 更新 README 文件 URL
Run Tests / test (pull_request) Successful in 32s
新增 11 個先前缺漏的 @OA 標註:
- Token Refresh(Member / Provider / Admin)
- Provider 資格驗證申請(show / upload / delete cert / submit)
- 即時訊息聊天室(unread-counts / list / send / mark-read)

其他:
- l5-swagger.php 標題改為 CFDive Platform API
- README 補充 Swagger UI URL 與 JSON 端點位置

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 15:08:15 +08:00
a620906209 4afe7b5fd5 Merge pull request 'chore(openspec): archive cloud-ready-p1 + sync main specs' (#51) from chore/openspec-archive-cloud-ready-p1 into master
Deploy Production / deploy (push) Successful in 14s
Run Tests / test (push) Successful in 32s
Reviewed-on: #51
2026-06-17 19:11:04 +00:00
a620906209 c3572682ad chore(openspec): 歸檔 cloud-ready-p1 + 同步主規格
Run Tests / test (pull_request) Successful in 30s
- 所有 task 驗證完成(7.1 stderr log / 7.2 無新增日誌檔 / 7.3 session 走 Redis)
- 同步 env-cloud-annotations 主 spec(LOG_CHANNEL 取代 LOG_STACK + 新增 scenario)
- 新增 scheduler-container 主 spec(獨立 container 取代 cron daemon)
- 移至 archive/2026-06-18-cloud-ready-p1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 03:05:57 +08:00
a620906209 13b6b4145c Merge pull request 'feat(infra): 雲端就緒 P1 — Session/Log/Scheduler 改造' (#50) from feat/cloud-ready-p1 into master
Deploy Production / deploy (push) Successful in 12s
Run Tests / test (push) Successful in 31s
Reviewed-on: #50
2026-06-17 18:34:47 +00:00
a620906209 d423cf5079 feat(infra): 雲端就緒 P1 — Session/Log/Scheduler 改造
Run Tests / test (pull_request) Successful in 29s
- Dockerfile:移除 cron 安裝與 scheduler crontab 設定
- docker-entrypoint.sh:移除 service cron start
- docker-compose.yml:新增 scheduler service(php artisan schedule:work)
- .env.example:SESSION_DRIVER=redis、LOG_CHANNEL=stderr、移除 LOG_STACK/LOG_DAILY_DAYS
- docs/STARTUP.md:新增 Log 查看、VPS 維護窗口操作與 Rollback 說明

VPS 部署需手動更新 .env 並執行 docker compose up -d --build(詳見 STARTUP.md §11)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 02:34:01 +08:00
a620906209 55b2bff1b8 Merge pull request 'chore(openspec): 歸檔 cloud-ready-zero-impact + 同步主規格' (#49) from feat/cloud-ready-zero-impact into master
Deploy Production / deploy (push) Successful in 12s
Run Tests / test (push) Successful in 29s
Reviewed-on: #49
2026-06-17 18:02:18 +00:00
a620906209 e08c6652a5 chore(openspec): 歸檔 cloud-ready-zero-impact + 同步主規格
Run Tests / test (pull_request) Successful in 29s
- openspec/changes/archive/2026-06-18-cloud-ready-zero-impact/:change 歸檔
- openspec/specs/compose-cloud-baseline/:新增主規格(healthcheck + override 隔離)
- openspec/specs/env-cloud-annotations/:新增主規格(.env.example 雲端標示)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 02:01:12 +08:00
a620906209 2d14c058d1 Merge pull request 'feat(infra): 雲端就緒零影響改動 (compose overlay + healthcheck + env)' (#48) from feat/cloud-ready-zero-impact into master
Deploy Production / deploy (push) Successful in 14s
Run Tests / test (push) Successful in 32s
Reviewed-on: #48
2026-06-17 17:49:29 +00:00
a620906209 be629feef9 feat(infra): 雲端就緒零影響改動 (compose overlay + healthcheck + env)
Run Tests / test (pull_request) Successful in 30s
- docker-compose.yml:app healthcheck 改用 nc -z 127.0.0.1 9000 驗證 PHP-FPM port
- docker-compose.yml:移除 phpmyadmin、mailpit(開發工具不屬於正式服務)
- compose.override.yml:新增,含 phpmyadmin(8081)、mailpit(8025)port mapping
- .env.example:QUEUE_CONNECTION 預設改 redis;補充 FILESYSTEM_DISK、LOG_STACK、SESSION_DRIVER 雲端注意事項
- docs/STARTUP.md:新增「部署模式」章節,明確區分 VPS 與雲端 -f 指令

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 01:48:24 +08:00
a620906209 998f34139a Merge pull request 'fix(ci): 修正 Alpine 3.23 PHP 套件名稱為 php84' (#47) from fix/vps-stability into master
Deploy Production / deploy (push) Successful in 16s
Run Tests / test (push) Successful in 38s
Reviewed-on: #47
2026-06-17 16:38:17 +00:00
a620906209 0080e2da84 fix(ci): 修正 Alpine 3.23 PHP 套件名稱為 php84
Run Tests / test (pull_request) Successful in 37s
Alpine 3.23 已無 php82 套件,改用 php84。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 00:37:46 +08:00
a620906209 4720dc53f9 Merge pull request 'fix(ci): 替換 setup-php 為 Alpine apk 直接安裝 PHP 8.2' (#46) from fix/vps-stability into master
Deploy Production / deploy (push) Successful in 17s
Run Tests / test (push) Failing after 12s
Reviewed-on: #46
2026-06-17 16:34:07 +00:00
a620906209 95a93b2d72 fix(ci): 替換 setup-php 為 Alpine apk 直接安裝 PHP 8.2
Run Tests / test (pull_request) Failing after 7s
shivammathur/setup-php 在 Alpine runner 上需要 sudo 且編譯耗時。
改用 apk add php82 系列套件,速度更快且相容 Alpine 環境。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 00:32:44 +08:00
a620906209 c0616b17df Merge pull request 'ci: 觸發 test workflow 驗證 Node.js 環境' (#45) from fix/vps-stability into master
Deploy Production / deploy (push) Successful in 18s
Run Tests / test (push) Failing after 5s
Reviewed-on: #45
2026-06-17 16:04:57 +00:00
a620906209 6a74f766db ci: 觸發 test workflow 驗證 Node.js 環境
Run Tests / test (pull_request) Has been cancelled
2026-06-18 00:04:28 +08:00
a620906209 d8340de322 Merge pull request 'fix(ci): test.yml 改用 self-hosted runner 避免 Docker-in-Docker 衝突' (#44) from fix/vps-stability into master
Deploy Production / deploy (push) Successful in 19s
Run Tests / test (push) Failing after 4s
Reviewed-on: #44
2026-06-17 15:49:27 +00:00
a620906209 aebce80152 fix(ci): test.yml 改用 self-hosted runner 避免 Docker-in-Docker 衝突
Run Tests / test (pull_request) Failing after 42s
ubuntu-latest 觸發 container 模式,導致 mkdirat var/run: file exists 錯誤。
改成 self-hosted 與 deploy.yml 一致,直接跑在 host 模式。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 23:48:27 +08:00
a620906209 5fdd5b3329 Merge pull request 'fix(vps): 加 TrustProxies、Redis 持久化 Volume、Log 改 daily' (#43) from fix/vps-stability into master
Deploy Production / deploy (push) Successful in 21s
Run Tests / test (push) Failing after 8s
Reviewed-on: #43
2026-06-17 15:44:47 +00:00
a620906209 daf72711e8 Merge pull request 'chore: 加入 Windsurf 規則與啟動文件' (#42) from chore/windsurf-startup-docs into master
Deploy Production / deploy (push) Successful in 25s
Run Tests / test (push) Successful in 2m14s
Reviewed-on: #42
2026-06-17 15:41:25 +00:00
a620906209 2fbcd0c50d fix(vps): 加 TrustProxies、Redis 持久化 Volume、Log 改 daily
Run Tests / test (pull_request) Successful in 2m15s
- bootstrap/app.php: trustProxies(at: ['*']) 讓 NPM 反向代理的 X-Forwarded-* header 被信任
- docker-compose.yml: Redis 加 redis-data volume,重啟後 session/queue/cache 不再清空
- .env.example: LOG_STACK 改 daily + LOG_DAILY_DAYS=14,避免 VPS 磁碟被單一 log 吃滿

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 23:33:36 +08:00
a620906209 979ea4df75 chore: 加入 Windsurf 規則與啟動文件
Run Tests / test (pull_request) Failing after 2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 23:31:52 +08:00
a620906209 6b1e31c4af Merge pull request 'docs: 補充測試帳號與功能概覽,修正 .env.example REVERB 區塊' (#41) from docs/interview-guest-accounts into master
Deploy Production / deploy (push) Successful in 16s
Run Tests / test (push) Successful in 2m3s
Reviewed-on: #41
2026-06-16 22:07:34 +00:00
a620906209 64874e1c12 docs: 補充測試帳號與功能概覽,修正 .env.example REVERB 區塊
Run Tests / test (pull_request) Successful in 2m0s
- README:更新功能概覽(Token 刷新、圖片壓縮、教練審核流程、測試欄)
  新增測試帳號表格與教練頁入口說明(/coach/login)
- .env.example:刪除誤植的 README 殘留行,補上完整 REVERB_* 與 VITE_REVERB_* 區塊
- .gitignore:忽略 .windsurf/rules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 05:59:01 +08:00
a620906209 19705fc8d7 Merge pull request 'chore(openspec): 歸檔 feature-test-coverage、provider-verification-workflow 變更' (#40) from chore/archive-changes-jun16 into master
Deploy Production / deploy (push) Successful in 16s
Run Tests / test (push) Successful in 1m50s
Reviewed-on: #40
2026-06-16 11:22:36 +00:00
a620906209 80f5cf179f chore(openspec): 歸檔 feature-test-coverage、provider-verification-workflow 變更
Run Tests / test (pull_request) Failing after 1m53s
兩個 change 全任務完成(feature-test-coverage 40/40、provider-verification-workflow 48/48)。

同步規格:
- feature-test-coverage/spec.md 補齊 ## Requirements 區塊標頭(結構修正)
- Admin 教練審核 scenario 改為 approve 端點(toggle-verified 已由 #36 移除)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:19:52 +08:00
a620906209 d99ffb0fd6 Merge pull request 'fix(tests): 同步 Feature test 與 provider-verification-workflow 架構變更' (#39) from fix/feature-test-sync into master
Deploy Production / deploy (push) Successful in 14s
Run Tests / test (push) Successful in 1m56s
Reviewed-on: #39
2026-06-16 11:18:04 +00:00
a620906209 afdca18c67 fix(tests): 同步 Feature test 與 provider-verification-workflow 架構變更
Run Tests / test (pull_request) Failing after 2s
provider-verification-workflow (#36) 在 feature-test-coverage (#38) merge 前
已將 is_verified 欄位替換為 verification_status 狀態機,並移除 toggle-verified 路由。

- 五個 Feature test 的 makeProvider helper 改用 verification_status='approved'
  (原 is_verified 已從 provider_profiles 欄位移除,靜默忽略導致 422/404)
- AdminUserManagementTest: toggle-verified 案例改為測試新的 approve 端點
  (PUT /admin/verifications/{userId}/approve,需 pending 狀態才可核准)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:14:25 +08:00
a620906209 4383239bab Merge pull request 'test(feature): 補齊五個核心業務流程 Feature test——Offer/Schedule CRUD、Admin 管理、通知觸發、預約列表' (#38) from feat/feature-test-coverage into master
Deploy Production / deploy (push) Successful in 19s
Run Tests / test (push) Failing after 1m50s
Reviewed-on: #38
2026-06-16 10:24:17 +00:00
a620906209 49a4b89655 test(feature): 補齊五個核心業務流程 Feature test——Offer/Schedule CRUD、Admin 管理、通知觸發、預約列表
Run Tests / test (pull_request) Successful in 1m51s
ProviderOfferCrudTest(7 案例):store/update/destroy 正常流程 + 所有權邊界(403)+ 未認證(401)
ProviderScheduleCrudTest(7 案例):store/update/destroy + 容量不變式(422)+ destroy 同步取消 active bookings
AdminUserManagementTest(5 案例):列表角色過濾 + toggle-active/toggle-verified DB 副作用 + 非 admin 403
NotificationTriggerTest(5 案例):5 條直接觸發路徑,收件者各不同(BookingCreated/CancelledByMember → provider;其餘 → member)
BookingListTest(4 案例):三角色資料隔離邊界 + 未認證 401

全套件 215 passed / 529 assertions,零 regression。
同步新增 openspec/changes/feature-test-coverage/ 與 openspec/specs/feature-test-coverage/spec.md。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 18:23:11 +08:00
a620906209 66ff30f863 Merge pull request 'test(unit): 補齊三個純邏輯 Unit test——狀態機、Enum 完整性、Email 正規化' (#37) from feat/unit-test-planning into master
Run Tests / test (push) Successful in 1m53s
Deploy Production / deploy (push) Successful in 11s
Reviewed-on: #37
2026-06-16 09:35:25 +00:00
a620906209 d6cc1cbca9 test(unit): 補齊三個純邏輯 Unit test——狀態機、Enum 完整性、Email 正規化
Run Tests / test (pull_request) Failing after 1m8s
BookingTransitionTest:驗證 canTransitionTo() 所有合法/非法轉換與終態不可逃脫(18 案例)
BookingStatusTest:驗證 7 個狀態完整性、from/tryFrom、VALID_TRANSITIONS 覆蓋一致性(10 案例)
NormalizesEmailTest:驗證大小寫與空白正規化邏輯(5 案例)

全部使用 PHPUnit 原生 TestCase,不需要資料庫連線,執行 1.82s。
全套件 187 passed / 464 assertions,零 regression。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 17:31:48 +08:00
a620906209 a18f52b2e8 Merge pull request 'chore(openspec): 歸檔 course-image-compression 變更' (#35) from chore/archive-cic into master
Deploy Production / deploy (push) Successful in 12s
Run Tests / test (push) Successful in 6m11s
Reviewed-on: #35
2026-06-11 19:41:00 +00:00
a620906209 e40507bd60 Merge pull request 'feat: 教練審核流程完整版——四狀態機、證照送審、Admin 裁決、通知(含測試清空 DB 重大修復)' (#36) from feat/provider-verification-workflow into master
Deploy Production / deploy (push) Successful in 33s
Run Tests / test (push) Successful in 1m54s
Reviewed-on: #36
2026-06-11 19:40:52 +00:00
a620906209 43720482c4 feat(verification): 教練審核流程完整版——狀態機/證照送審/Admin 裁決/通知
Run Tests / test (pull_request) Successful in 2m3s
實作 openspec change provider-verification-workflow(25/25 tasks):
- is_verified 升級為 verification_status 四狀態機(unsubmitted/pending/
  approved/rejected),migration 自動轉換既有資料,API 以 accessor 保留
  is_verified 相容輸出
- 教練端:證照上傳(≤3 張、複用 CompressesImages)、送審、駁回原因
  顯示與重送(/coach/verification 新頁面)
- Admin 端:審核佇列、通過/駁回(原因必填)、撤銷 approved;移除
  toggle-verified 端點(防繞過狀態機);裁決後 flush 課程快取
- 通知:審核通過/駁回(站內+Email,駁回含原因)
- 可見性與預約入口改判 approved(行為等價)
- 測試 +18(ProviderVerificationTest 10、AdminVerificationTest 8),
  既有 4 個測試檔 helper 遷移;Swagger 同步
- 規格同步:provider-verification 全面改寫、admin-user-management
  toggle 段落改為移除聲明

容器內全套件 173 passed / 439 assertions。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:26:12 +08:00
a620906209 37140554d3 fix(test): 防止容器內測試清空開發用 MySQL——bootstrap 強制覆蓋 $_SERVER
根因:docker compose 將 DB_CONNECTION=mysql 注入為真實環境變數,
PHPUnit 的 <env force> 只覆蓋 $_ENV/putenv,但 Laravel env() 優先讀
$_SERVER,導致 RefreshDatabase 一直在清空開發 DB(今日實際發生三次,
也是先前『DB 神秘清空』的元凶)。

- 新增 tests/bootstrap.php 於載入前同步覆蓋 $_SERVER/$_ENV/putenv
- phpunit.xml bootstrap 指向新檔,DB env 加 force 屬性
- 驗證:容器內全套件後 MySQL 資料存活(users=13)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:25:54 +08:00
a620906209 a7d5f81890 chore(openspec): 歸檔 course-image-compression 變更
Run Tests / test (pull_request) Successful in 1m58s
實作已 merge(PR #34),規格增量已於實作時同步至主規格。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:53:15 +08:00
a620906209 c53e9657b3 Merge pull request 'feat(images): 課程圖片伺服器端壓縮(O3.1)——scaleDown 2048 + JPEG 85、上限放寬至 10MB' (#34) from feat/course-image-compression into master
Deploy Production / deploy (push) Successful in 1m4s
Run Tests / test (push) Successful in 2m27s
Reviewed-on: #34
2026-06-11 15:47:34 +00:00
a620906209 6414222a18 feat(images): 課程圖片伺服器端壓縮——scaleDown 2048 + JPEG 85,上限放寬至 10MB
Run Tests / test (pull_request) Successful in 2m0s
實作 openspec change course-image-compression(O3.1 體感優化):
- 新增 App\Traits\CompressesImages:與聊天圖片管線參數一致的共用壓縮邏輯
- CourseImageController 封面/相簿套用壓縮,上傳上限 2MB→10MB(容納手機原圖)
- 前端課程卡片與相簿縮圖加 loading=lazy(詳情頁首屏封面刻意不加)
- CourseImageTest +4 案例(10MB 邊界、轉存 .jpg、>2048 縮小、小圖不放大)
- course-image-upload 主規格同步(新增伺服器端壓縮 requirement)

容器內全套件 155 passed / 400 assertions。
聊天端改用 trait 留待後續(該處無測試覆蓋,不在本 change 重構)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:43:24 +08:00
a620906209 87a4f70e65 Merge pull request 'chore(openspec): 歸檔 provider-verification-gaps 變更' (#32) from chore/archive-pvg into master
Deploy Production / deploy (push) Successful in 29s
Run Tests / test (push) Failing after 2m16s
Reviewed-on: #32
2026-06-11 15:21:02 +00:00
a620906209 99651b0bac Merge pull request 'perf(docker): Phase 1 效能優化——OPcache revalidate 調校、composer 重裝修正、healthcheck 修復' (#33) from perf/phase1-opcache into master
Deploy Production / deploy (push) Successful in 16s
Run Tests / test (push) Successful in 2m14s
Reviewed-on: #33
2026-06-11 15:20:41 +00:00
a620906209 07618fb1b3 perf(docker): OPcache revalidate 調校 + composer 重裝條件修正 + nginx healthcheck 修復
Run Tests / test (pull_request) Successful in 1m56s
實測修正根因:fpm 的 OPcache 本來就啟用(初版診斷是 CLI 假象),
真凶是 revalidate_freq=2 在 Windows bind mount 上每 2 秒重新 stat
全部腳本——窗口內 0.23s、窗口外 2.5s。

- local.ini:revalidate_freq 2→30、memory 192M、max_files 20000
  (程式碼變更最多 30s 生效,立即生效用 kill -USR2 1 平滑重載)
- entrypoint:composer 重裝改用 lock 內容比對,git 分支操作不再
  觸發開機重裝(原 mtime 比對造成數分鐘 502 窗口)
- healthcheck:localhost→127.0.0.1(busybox wget 先解析 ::1 而
  nginx 只 listen IPv4,導致永遠 unhealthy)

驗收:穩態 0.20~0.27s(修復前 2.3~2.5s,約 10 倍);nginx healthy。
量測數據已寫回優化計畫文檔。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:15:55 +08:00
a620906209 5825e74d2d chore(openspec): 歸檔 provider-verification-gaps 變更
Run Tests / test (pull_request) Successful in 1m59s
實作已 merge(PR #31),規格增量已於實作時同步至主規格。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:59:32 +08:00
a620906209 3384210c5c Merge pull request 'feat: 補齊 provider-verification 可見性缺口——子端點過濾與預約入口檢查' (#31) from feat/provider-verification-gaps into master
Deploy Production / deploy (push) Successful in 17s
Run Tests / test (push) Successful in 2m7s
Reviewed-on: #31
2026-06-11 14:52:54 +00:00
a620906209 90d0a57e73 feat(offers): 補齊 provider-verification 可見性缺口——子端點過濾與預約入口檢查
Run Tests / test (pull_request) Successful in 1m56s
實作 openspec change provider-verification-gaps(8/8 tasks):
- schedules/reviews publicList 套用 visibleToPublic,隱藏課程回 404
- MemberBookingController::store Layer 1 擋未驗證教練課程的新預約(422)
- 政策:教練被取消驗證只擋新預約,既有 pending/confirmed/completed 照常
- 主規格 Notes 已知限制轉為正式 Requirements
- 測試 +5(可見性子端點 3、預約入口 2),既有 booking/review 測試 helper
  補 verified ProviderProfile
- 容器內全套件 151 passed / 388 assertions;另以 demo 資料端到端驗證
  (已驗證教練 200/200/200、未驗證 404/404/404)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:48:59 +08:00
a620906209 c226275fc2 Merge pull request 'docs: 稽核文檔收斂為執行紀錄、開 provider-verification-gaps change 規劃遺留缺口' (#30) from docs/post-audit-planning into master
Deploy Production / deploy (push) Successful in 19s
Run Tests / test (push) Successful in 2m4s
Reviewed-on: #30
2026-06-11 14:33:12 +00:00
a620906209 99cf35c1ec docs: 稽核文檔改為執行紀錄、開 provider-verification-gaps change 規劃遺留缺口
Run Tests / test (pull_request) Successful in 2m1s
- 稽核報告:已完成的修補計畫改寫為執行紀錄(commit 對照表 + 偏差說明),
  未處理事項收斂為三項(verification 缺口 / 效能優化 / 新功能路線圖)
- 新 change provider-verification-gaps:規劃 schedules/reviews 公開子端點
  過濾與預約入口檢查(含「既有預約不受影響」政策決定),待實作

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:06:34 +08:00
a620906209 520ec487ee Merge pull request 'chore(openspec): 補歸檔 2026-06-11-audit-remediation change' (#29) from fix/audit-remediation into master
Deploy Production / deploy (push) Successful in 16s
Run Tests / test (push) Successful in 1m53s
Reviewed-on: #29
2026-06-11 10:46:30 +00:00
a620906209 a97a7d096c chore(openspec): 補歸檔 2026-06-11-audit-remediation change
Run Tests / test (pull_request) Successful in 2m2s
事後補齊本次稽核修補的 OpenSpec 歷史紀錄(proposal / design /
tasks / 規格增量),主規格已於實作時直接更新,本 archive 僅為
變更軌跡。design.md 記錄 5 項關鍵決策(artisan command 取代
HTTP 端點、10/min 為基準、列表過濾而非禁止上架等)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:44:14 +08:00
a620906209 0dffca31bb Merge pull request 'security: 2026-06-11 稽核修補——封鎖 admin/register P0 漏洞、is_verified 業務約束、預約核心測試' (#28) from fix/audit-remediation into master
Deploy Production / deploy (push) Successful in 20s
Run Tests / test (push) Successful in 2m9s
Reviewed-on: #28
2026-06-11 10:21:31 +00:00
a620906209 7ca91da138 docs: 操作體驗優化計畫——實測定位 OPcache 停用 + bind mount 為主因
Run Tests / test (pull_request) Successful in 2m7s
實測:API 快取命中仍 2.49s、容器測試比本機慢 14 倍;
容器內 opcache 已載入但 disabled。計畫分三階段,O1.1 啟用
OPcache 為最高投報率項目。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:55:56 +08:00
a620906209 cec2078035 test(auth): 同步 admin register 端點移除——刪除過時測試、更新覆蓋規格
原 test_admin_register_* 測的是已移除的 P0 漏洞端點,
端點關閉防護與帳號建立改由 AdminAccountCreationTest 覆蓋。
容器內全套件 146 passed / 378 assertions。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:52:37 +08:00
a620906209 d3a0d300c6 docs(openspec): 規格清理——補 Purpose、修正前端 repo 描述、補 admin 查詢端點
- auth-test-coverage:補歸檔後遺留的 TBD Purpose
- member-portal-ui:前端實際位於本 repo frontend/,修正獨立 repo 描述
- admin-auth:補 check-member / check-provider 端點規格

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:49:00 +08:00
a620906209 63b25f96ac test(booking): 補預約核心測試——狀態機/防超賣/Scheduler/聊天授權/Admin 權限邊界
稽核 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 <noreply@anthropic.com>
2026-06-11 17:46:46 +08:00
a620906209 3c38d085bd feat(offers): 未驗證教練的課程不對公開端點曝光
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 <noreply@anthropic.com>
2026-06-11 17:42:43 +08:00
a620906209 88a81aac41 docs(openspec): 同步 login-rate-limiting 規格門檻至實作值 10/min
規格原寫 5/min,實作與測試(commit 0dabc4e)皆為 throttle:10,1,
以實作為準消除規格漂移,並註記放寬理由(帳號鎖定已涵蓋暴力破解防護)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:37:19 +08:00
a620906209 aeb8c975e2 security(auth): 移除公開 admin/register 端點,改由 app:create-admin 建立管理員
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 <noreply@anthropic.com>
2026-06-11 17:36:33 +08:00
a620906209 51784ecc64 Merge pull request 'fix(test): 修正 AuthRateLimitTest 過期的 throttle 門檻斷言' (#27) from test/auth-tests-coverage into master
Run Tests / test (push) Successful in 2m35s
Deploy Production / deploy (push) Successful in 17s
Reviewed-on: #27
2026-06-07 20:06:03 +00:00
a620906209 ca5b843362 chore(openspec): 歸檔 auth-tests 變更並建立 auth-test-coverage 主規格
Run Tests / test (pull_request) Failing after 43s
34 項驗收情境與測試已 1:1 對應且全數通過,將變更移至
openspec/changes/archive/2026-06-07-auth-tests/,並依其內容建立
openspec/specs/auth-test-coverage/spec.md 作為主規格文件。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 04:02:05 +08:00
a620906209 6f446f601f test(auth): 新增三角色登入/註冊/登出、P2 帳號鎖定、OAuth state 驗證測試
新增 34 個 Feature 測試(AuthLoginTest 19、AuthLockoutTest 11、
AuthOAuthTest 4)涵蓋 member/provider/admin 的註冊、登入、登出與跨角色
隔離;P2 帳號鎖定的 Fixed Window 計數、companion cache key 與 email
正規化;以及 P2 OAuth state 的 CSRF 驗證與一次性消耗。

同時新增 .gitea/workflows/test.yml 在 push/PR 時於 CI 自動執行測試
(含 GD 等擴充套件安裝),並補上對應的 OpenSpec 變更文件
(proposal/design/spec/tasks,34 項驗收情境與測試 1:1 對應)。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 04:01:33 +08:00
a620906209 0dabc4ebb5 fix(test): 修正 AuthRateLimitTest 過期的 throttle 門檻斷言
commit 2a0e9255(P2 帳號鎖定上線)已將 member/provider 登入的 IP throttle
從 5,1 調整為 10,1(避免 IP throttle 搶在帳號鎖定的 423 之前觸發 429),
但測試的迴圈次數與註解仍停留在舊版 5,1,導致預期 429 卻收到 401。
同步將迴圈次數改為 10、區塊註解改為 throttle:10,1,使其對齊現行設定。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 04:01:20 +08:00
a620906209 ca1b83a2e3 Merge pull request 'Merge branch 'master' into feat/demo-seeder' (#26) from feat/demo-seeder into master
Deploy Production / deploy (push) Successful in 10s
Reviewed-on: #26
2026-06-02 17:51:49 +00:00
a620906209 855d9e1d4a Merge branch 'master' into feat/demo-seeder 2026-06-02 17:50:01 +00:00
a620906209 981f753480 Merge pull request 'fix(auth): 補 StartSession middleware 給 OAuth routes' (#24) from security/auth-p2-lockout-oauth-state into master
Deploy Production / deploy (push) Successful in 12s
Reviewed-on: #24
2026-06-02 17:47:57 +00:00
a620906209 3b90edf843 fix(auth): 補 StartSession middleware 給 OAuth routes
api.php 的 route 預設無 session middleware,
移除 stateless() 後 session() 呼叫直接 500。
只對 /auth/google/* 兩個 route 加 StartSession,不影響其他 API endpoint。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:44:29 +08:00
a620906209 b06d8524db Merge pull request 'security(auth): P2 帳號鎖定 + OAuth state 驗證' (#23) from security/auth-p2-lockout-oauth-state into master
Deploy Production / deploy (push) Successful in 31s
Reviewed-on: #23
2026-06-02 17:39:02 +00:00
a620906209 2a0e9255ae security(auth): P2 帳號鎖定 + OAuth state 驗證
- Account lockout:連續失敗 5 次鎖定 15 分鐘(Fixed Window,Cache)
  - Member / Provider 各自獨立 namespace
  - Email 正規化(strtolower+trim)
  - 不存在帳號不累計計數(防 Cache 污染)
  - companion key 記錄 locked_until(ISO 8601)
- OAuth CSRF 防護:移除 stateless(),手動 state 生成與驗證
  - session('oauth_state') + session('state') 雙寫確保 Socialite 內建驗證通過
  - state mismatch redirect 至 /login?error=oauth_failed
- throttle 從 5,1 調整為 10,1,避免 IP throttle 在 lockout 前觸發
- NormalizesEmail Trait、config/auth_lockout.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:20:25 +08:00
a620906209 b2e20767d8 Merge pull request 'security(auth): P1 Token Refresh + sessionStorage' (#22) from security/auth-p1-token-refresh into master
Deploy Production / deploy (push) Successful in 30s
Reviewed-on: #22
2026-06-01 20:34:52 +00:00
a620906209 d9f0fe11c6 security(auth): P1 Token Refresh + sessionStorage
- 新增三個 refresh 端點(member/provider/admin),revoke 舊 token 發行新 7 天 token
- axios.js / coachAxios.js:401 interceptor 改為 refresh-then-retry,含 isRefreshing queue 防並發、isRedirecting flag 防雙重 redirect
- auth.js / coachAuth.js / AuthCallbackView.vue:localStorage 全改為 sessionStorage
- echo.js / notificationAxios.js:同步改為 sessionStorage
- notifications.js:401 時停止 polling(stopPolling),不印 error log
- 新增 TokenRefreshTest.php(11 tests / 24 assertions):refresh 成功/失效/跨角色 403

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 04:33:35 +08:00
a620906209 a94c2462b8 Merge pull request 'security(auth): P0 認證安全強化' (#21) from security/auth-p0-hardening into master
Deploy Production / deploy (push) Successful in 36s
Reviewed-on: #21
2026-06-01 17:58:24 +00:00
a620906209 cb9c9a9385 security(auth): P0 認證安全強化
- Token 過期時間設為 7 天(sanctum.php expiration)
- prune-expired 每日排程清除過期 token 記錄
- Google OAuth redirect 改用 URL fragment(#token=)避免 token 出現在 server log
- SocialAuthController env() 改 config() 修正 config:cache 下 redirect 失效問題
- 登入端點加 rate limiting:member/provider throttle:5,1,admin throttle:3,1
- axios/coachAxios 加 401 response interceptor,token 過期自動登出
- 修正 migration SQLite 相容性(MODIFY COLUMN),Feature tests 從 7 通過恢復至 41
- 新增 AuthRateLimitTest(5 tests)驗證各角色頻率限制行為

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 01:54:40 +08:00
a620906209 5b13a014be Merge pull request 'docs: 更新 README 反映最新功能與技術棧' (#20) from docs/update-readme into master
Deploy Production / deploy (push) Successful in 14s
Reviewed-on: #20
2026-05-28 19:18:17 +00:00
a620906209 8d1ea49abc docs: 更新 README 反映最新功能與技術棧
新增:即時訊息、Reverb WebSocket、Sentry、/health、CI/CD、本地開發說明。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 03:17:44 +08:00
179 changed files with 9802 additions and 472 deletions
+22 -5
View File
@@ -15,8 +15,7 @@ APP_MAINTENANCE_STORE=database
BCRYPT_ROUNDS=12 BCRYPT_ROUNDS=12
LOG_CHANNEL=stack LOG_CHANNEL=stderr
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
@@ -28,15 +27,15 @@ DB_CONNECTION=sqlite
# DB_PASSWORD= # DB_PASSWORD=
# MYSQL_ROOT_PASSWORD= # MYSQL_ROOT_PASSWORD=
SESSION_DRIVER=database SESSION_DRIVER=redis
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=null
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local # 雲端應改為 s3(local 在容器重啟後會遺失上傳檔案)
QUEUE_CONNECTION=database QUEUE_CONNECTION=redis # 雲端建議值;database 效能較差且不利擴容
CACHE_STORE=redis CACHE_STORE=redis
CACHE_PREFIX= CACHE_PREFIX=
@@ -67,3 +66,21 @@ VITE_APP_NAME="${APP_NAME}"
SENTRY_LARAVEL_DSN= SENTRY_LARAVEL_DSN=
SENTRY_ENVIRONMENT="${APP_ENV}" SENTRY_ENVIRONMENT="${APP_ENV}"
REVERB_APP_ID=
REVERB_APP_KEY=
REVERB_APP_SECRET=
REVERB_HOST=localhost
REVERB_PORT=8085
REVERB_SCHEME=http
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
# Swagger UI — API 伺服器 Base URL(結尾不加斜線)
# 本地:http://localhost:8080/api
# 生產:https://api.hank-space.com/api
L5_SWAGGER_CONST_HOST=http://localhost:8080/api
L5_SWAGGER_GENERATE_ALWAYS=false
+5
View File
@@ -33,6 +33,11 @@ jobs:
docker compose exec -T app php artisan view:cache docker compose exec -T app php artisan view:cache
docker compose exec -T app php artisan event:cache docker compose exec -T app php artisan event:cache
- name: Generate Swagger docs
run: |
cd /root/myproject/CFDivePlatform
docker compose exec -T app php artisan l5-swagger:generate
- name: Restart queue worker - name: Restart queue worker
run: | run: |
cd /root/myproject/CFDivePlatform cd /root/myproject/CFDivePlatform
+38
View File
@@ -0,0 +1,38 @@
name: Run Tests
on:
push:
branches:
- master
pull_request:
jobs:
test:
runs-on: self-hosted
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
run: |
apk add --no-cache \
php84 php84-phar php84-mbstring php84-dom php84-fileinfo php84-gd \
php84-pdo php84-pdo_sqlite php84-sqlite3 php84-tokenizer \
php84-xml php84-xmlwriter php84-ctype php84-curl php84-openssl \
php84-json php84-session php84-iconv \
curl unzip
ln -sf /usr/bin/php84 /usr/local/bin/php
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet
- name: Copy .env
run: cp .env.example .env
- name: Install Composer dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Generate application key
run: php artisan key:generate
- name: Run tests
run: php artisan test
+1
View File
@@ -27,3 +27,4 @@ yarn-error.log
/frontend/dist /frontend/dist
/frontend/.env /frontend/.env
@@ -0,0 +1,26 @@
# Ponytail, lazy senior dev mode
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
## Before writing any code
Stop at the first rung that holds:
- Does this need to be built at all? (YAGNI)
- Does the standard library already do this? Use it.
- Does a native platform feature cover it? Use it.
- Does an already-installed dependency solve it? Use it.
- Can this be one line? Make it one line.
- Only then: write the minimum code that works.
## Rules
- No abstractions that weren't explicitly requested.
- No new dependency if it can be avoided.
- No boilerplate nobody asked for.
- Deletion over addition. Boring over clever. Fewest files possible.
- Question complex requests: "Do you actually need X, or does Y cover it?"
- Pick the edge-case-correct option when two stdlib approaches are the same size, lazy means less code, not the flimsier algorithm.
- Mark intentional simplifications with a ponytail: comment. If the shortcut has a known ceiling (global lock, O(n²) scan, naive heuristic), the comment names the ceiling and the upgrade path.
Not lazy about: input validation at trust boundaries, error handling that prevents data loss, security, accessibility, the calibration real hardware needs (the platform is never the spec ideal, a clock drifts, a sensor reads off), anything explicitly requested. Lazy code without its check is unfinished: non-trivial logic leaves ONE runnable check behind, the smallest thing that fails if the logic breaks (an assert-based demo/self-check or one small test file; no frameworks, no fixtures). Trivial one-liners need no test.
+1 -8
View File
@@ -17,8 +17,7 @@ RUN apt-get update && apt-get install -y \
libzip-dev \ libzip-dev \
default-mysql-client \ default-mysql-client \
netcat-openbsd \ netcat-openbsd \
grep \ grep
cron
# 清理 apt 快取以減小鏡像大小 # 清理 apt 快取以減小鏡像大小
RUN apt-get clean && rm -rf /var/lib/apt/lists/* RUN apt-get clean && rm -rf /var/lib/apt/lists/*
@@ -53,12 +52,6 @@ COPY docker/php/local.ini /usr/local/etc/php/conf.d/local.ini
COPY docker/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh COPY docker/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# 加入 Laravel Scheduler cron job
RUN echo "* * * * * www-data php /var/www/artisan schedule:run >> /var/log/laravel-scheduler.log 2>&1" \
> /etc/cron.d/laravel-scheduler \
&& chmod 0644 /etc/cron.d/laravel-scheduler \
&& crontab /etc/cron.d/laravel-scheduler
# 設置容器啟動時執行的入口點 # 設置容器啟動時執行的入口點
# 這將在 CMD 指令之前執行 # 這將在 CMD 指令之前執行
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+59 -11
View File
@@ -1,6 +1,6 @@
# CFDive Platform # CFDive Platform
潛水課程媒合平台 — 連結潛水教練與學員,提供課程瀏覽、線上預約、評價與通知等完整服務。 潛水課程媒合平台 — 連結潛水教練與學員,提供課程瀏覽、線上預約、即時訊息、評價與通知等完整服務。
--- ---
@@ -8,19 +8,24 @@
**會員(Member** **會員(Member**
- 註冊 / 登入(Email + Google OAuth - 註冊 / 登入(Email + Google OAuth
- Token 自動續期(401 refresh-then-retrysessionStorage 儲存)
- 瀏覽、搜尋、篩選潛水課程 - 瀏覽、搜尋、篩選潛水課程
- 查看課程時段並送出預約 - 查看課程時段並送出預約
- 與教練即時訊息(文字 + 圖片,含已讀回執)
- 對完成的課程留下評價(支援匿名、有幫助投票) - 對完成的課程留下評價(支援匿名、有幫助投票)
- 站內通知(Email + Polling - 站內通知(Bell Icon 即時更新 + 瀏覽器推播
**教練(Provider** **教練(Provider**
- 課程 CRUD(含封面 + 相簿圖片上傳) - 課程 CRUD(含封面 + 相簿圖片上傳,伺服器端壓縮
- 課程時段管理 - 課程時段管理
- 預約管理(確認 / 拒絕 / 完成 / 取消) - 預約管理(確認 / 拒絕 / 完成 / 取消)
- 與學員即時訊息
- 證照上傳與教練資格送審
**管理員(Admin** **管理員(Admin**
- 平台統計數據(會員數、教練數、課程數) - 平台統計數據(會員數、教練數、課程數)
- 會員與教練帳號管理(啟用 / 停用 / 審核 - 會員與教練帳號管理(啟用 / 停用)
- 教練資格審核(送審 / 通過 / 駁回 / 撤銷)
- 課程、預約、評價管理 - 課程、預約、評價管理
--- ---
@@ -30,20 +35,63 @@
| 層級 | 技術 | | 層級 | 技術 |
|------|------| |------|------|
| 後端 | PHP 8.x / Laravel 11 | | 後端 | PHP 8.x / Laravel 11 |
| 前端 | Vue 3 | | 前端 | Vue 3 + Vite + Tailwind CSS |
| 資料庫 | MySQL 8.0 | | 資料庫 | MySQL 8.0 |
| 快取 | Redispredis| | 快取 | Redis |
| 認證 | Laravel Sanctum + Google OAuth | | 即時通訊 | Laravel ReverbWebSocket`wss://ws.hank-space.com`|
| 認證 | Laravel Sanctum + Google OAuth + Token Refresh |
| 容器 | Docker / Docker Compose | | 容器 | Docker / Docker Compose |
| API 文件 | Swagger UIl5-swagger| | API 文件 | Swagger UIl5-swagger|
| 錯誤監控 | Sentry |
| CI/CD | Gitea Actions(自動部署至 VPS|
| 測試 | PHPUnit / Laravel Feature & Unit Tests |
--- ---
## API 文件 ## API 文件
共 73 個端點,涵蓋 Swagger UI(由 l5-swagger 生成)涵蓋所有端點,包含
- 認證(Email + Google OAuth - 認證(Email + Google OAuth、Token 刷新
- 公開課程查詢 - 公開課程查詢
- 會員預約 / 評價 / 通知 - 會員預約 / 即時訊息 / 評價 / 通知
- 教練課程 / 時段 / 預約管理 - 教練課程 / 時段 / 預約管理 / 資格驗證申請(證照送審)
- 管理員後台 - 管理員後台
| 環境 | Swagger UI |
|------|-----------|
| 本地 | http://localhost:8080/api/documentation |
| 生產 | https://api.hank-space.com/api/documentation |
---
## 健康檢查
```
GET /health
```
回傳 DB、Redis、Cache 狀態,供 UptimeRobot 監控使用。全部正常回 200,任一異常回 503。
---
## 本地開發
**測試用帳號**
| 角色 | 帳號 | 密碼 | 用途 |
|------|------|------|------|
| 會員 | Guest@cfdive.com | guestpassword | 體驗課程瀏覽、預約、會員預約紀錄 |
| 教練 | Guest_Coach@cfdive.com | coachpassword | 體驗課程管理、時段管理、預約管理 |
**教練頁入口**
目前首頁尚未提供明顯導引到教練後台的入口。若要體驗教練功能,請直接開啟 `/coach/login`,使用上方教練試用帳號登入後會進入 `/coach/dashboard`
| 服務 | 本地 | 生產 |
|------|------|------|
| 前端 | http://localhost:8080 | https://app.hank-space.com |
| API | http://localhost:8080/api | https://api.hank-space.com/api |
| Swagger UI | http://localhost:8080/api/documentation | https://api.hank-space.com/api/documentation |
| phpMyAdmin | http://localhost:8081 | http://\<vps-ip\>:8081 |
| Mailpit | http://localhost:8025 | http://\<vps-ip\>:8025 |
| Reverb WebSocket | ws://localhost:8085 | wss://ws.hank-space.com |
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Console\Commands;
use App\Models\AdminProfile;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
class CreateAdminUser extends Command
{
protected $signature = 'app:create-admin
{name : 管理員姓名}
{email : 登入用電子郵件}
{--password= : 密碼(至少 8 碼,未提供時互動式輸入)}
{--position= : 職位}
{--department= : 部門}';
protected $description = '建立管理員帳號(公開 /api/admin/register 端點已移除,管理員一律由主機端建立)';
public function handle(): int
{
$password = $this->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;
}
}
+68 -13
View File
@@ -283,30 +283,85 @@ class AdminApiDoc
} }
/** /**
* 切換教練審核狀態 * 教練審核佇列
* *
* @OA\Put( * @OA\Get(
* path="/admin/providers/{id}/toggle-verified", * path="/admin/verifications",
* summary="切換教練審核狀態", * summary="教練審核佇列",
* description="通過或撤銷教練審核,回傳新的 is_verified 狀態", * description="查詢教練驗證申請(預設僅 pending;status=all 可查全部),含證照圖片 URL",
* operationId="toggleProviderVerified", * operationId="adminVerificationIndex",
* tags={"Admin 教練管理"}, * tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}}, * security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")), * @OA\Parameter(name="status", in="query", required=false, description="unsubmitted / pending / approved / rejected / all", @OA\Schema(type="string", default="pending")),
* @OA\Response( * @OA\Response(
* response=200, * response=200,
* description="切換成功", * description="查詢成功",
* @OA\JsonContent( * @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="教練已通過審核"), * @OA\Property(property="data", type="array", @OA\Items(
* @OA\Property(property="is_verified", type="boolean", example=true) * @OA\Property(property="user_id", type="integer", example=5),
* @OA\Property(property="name", type="string", example="王教練"),
* @OA\Property(property="email", type="string", example="coach@example.com"),
* @OA\Property(property="business_name", type="string", example="藍海潛水"),
* @OA\Property(property="verification_status", type="string", example="pending"),
* @OA\Property(property="rejection_reason", type="string", nullable=true, example=null),
* @OA\Property(property="certifications", type="array", @OA\Items(
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="url", type="string", example="http://localhost:8080/storage/providers/5/certifications/uuid.jpg")
* ))
* ))
* ) * )
* ), * ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
public function toggleProviderVerified() public function adminVerificationIndex()
{
}
/**
* 通過教練審核
*
* @OA\Put(
* path="/admin/verifications/{userId}/approve",
* summary="通過教練審核",
* description="將 pending 教練轉為 approved,課程恢復公開曝光並通知教練",
* operationId="adminVerificationApprove",
* tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="userId", in="path", required=true, description="教練使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(response=200, description="已通過審核"),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="當前狀態無法通過審核", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function adminVerificationApprove()
{
}
/**
* 駁回教練審核
*
* @OA\Put(
* path="/admin/verifications/{userId}/reject",
* summary="駁回教練審核",
* description="駁回 pending 教練或撤銷 approved 教練,原因必填並通知教練",
* operationId="adminVerificationReject",
* tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="userId", in="path", required=true, description="教練使用者 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(required=true, @OA\JsonContent(
* required={"reason"},
* @OA\Property(property="reason", type="string", maxLength=500, example="證照影像不清晰,請重新拍攝上傳")
* )),
* @OA\Response(response=200, description="已駁回"),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="原因未填或狀態不可駁回", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function adminVerificationReject()
{ {
} }
+97 -53
View File
@@ -12,7 +12,7 @@ use OpenApi\Annotations as OA;
* ) * )
* *
* @OA\Server( * @OA\Server(
* url="/api", * url=L5_SWAGGER_CONST_HOST,
* description="API 伺服器" * description="API 伺服器"
* ) * )
* *
@@ -207,6 +207,38 @@ class AuthApiDoc
{ {
} }
/**
* 會員 Token 刷新
*
* @OA\Post(
* path="/member/refresh",
* summary="會員 Token 刷新",
* description="撤銷當前 Token 並發放新的 Bearer token(不需要重新登入)",
* operationId="refreshMember",
* tags={"會員"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="刷新成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="token", type="string", example="24|newtoken..."),
* @OA\Property(property="token_type", type="string", example="Bearer")
* )
* ),
* @OA\Response(
* response=401,
* description="未認證",
* @OA\JsonContent(
* @OA\Property(property="message", type="string", example="Unauthenticated.")
* )
* )
* )
*/
public function refreshMember()
{
}
/** /**
* 會員登出 * 會員登出
* *
@@ -580,6 +612,38 @@ class AuthApiDoc
{ {
} }
/**
* 服務提供者 Token 刷新
*
* @OA\Post(
* path="/provider/refresh",
* summary="服務提供者 Token 刷新",
* description="撤銷當前 Token 並發放新的 Bearer token",
* operationId="refreshProvider",
* tags={"服務提供者"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="刷新成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="token", type="string", example="25|newtoken..."),
* @OA\Property(property="token_type", type="string", example="Bearer")
* )
* ),
* @OA\Response(
* response=401,
* description="未認證",
* @OA\JsonContent(
* @OA\Property(property="message", type="string", example="Unauthenticated.")
* )
* )
* )
*/
public function refreshProvider()
{
}
/** /**
* 服務提供者登出 * 服務提供者登出
* *
@@ -825,58 +889,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()
{
}
/** /**
* 管理員登入 * 管理員登入
* *
@@ -958,6 +970,38 @@ class AuthApiDoc
{ {
} }
/**
* 管理員 Token 刷新
*
* @OA\Post(
* path="/admin/refresh",
* summary="管理員 Token 刷新",
* description="撤銷當前 Token 並發放新的 Bearer token",
* operationId="refreshAdmin",
* tags={"管理員"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="刷新成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="token", type="string", example="26|newtoken..."),
* @OA\Property(property="token_type", type="string", example="Bearer")
* )
* ),
* @OA\Response(
* response=401,
* description="未認證",
* @OA\JsonContent(
* @OA\Property(property="message", type="string", example="Unauthenticated.")
* )
* )
* )
*/
public function refreshAdmin()
{
}
/** /**
* 管理員登出 * 管理員登出
* *
+150
View File
@@ -17,6 +17,10 @@ use OpenApi\Annotations as OA;
* name="通知", * name="通知",
* description="站內通知管理(Member / Provider 共用)" * description="站內通知管理(Member / Provider 共用)"
* ) * )
* @OA\Tag(
* name="即時訊息",
* description="預約聊天室訊息(Member / Provider 共用,僅限 confirmed / completed 預約的參與方)"
* )
*/ */
class MemberApiDoc class MemberApiDoc
{ {
@@ -277,6 +281,152 @@ class MemberApiDoc
{ {
} }
// -----------------------------------------------------------------------
// Booking Messages(即時訊息,Member + Provider 共用)
// -----------------------------------------------------------------------
/**
* 取得各預約未讀訊息數
*
* @OA\Get(
* path="/bookings/messages/unread-counts",
* summary="取得各預約未讀訊息數",
* description="回傳當前使用者每個預約中對方發送的未讀訊息數量(Key 為 booking_id",
* operationId="getMessageUnreadCounts",
* tags={"即時訊息"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="object",
* description="Key 為 booking_idstring),value 為未讀數(integer",
* example={"12": 3, "17": 1}
* )
* )
* )
* )
*/
public function getMessageUnreadCounts()
{
}
/**
* 取得預約訊息列表
*
* @OA\Get(
* path="/bookings/{booking}/messages",
* summary="取得預約訊息列表",
* description="回傳指定預約的所有訊息(依時間升冪排列),僅限預約參與方存取,且預約狀態需為 confirmed 或 completed",
* operationId="listBookingMessages",
* tags={"即時訊息"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="booking", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="booking_id", type="integer", example=12),
* @OA\Property(property="sender_type", type="string", enum={"member","provider"}, example="member"),
* @OA\Property(property="message", type="string", nullable=true, example="請問需要自備潛水裝備嗎?"),
* @OA\Property(property="image_url", type="string", nullable=true, example=null),
* @OA\Property(property="read_at", type="string", nullable=true, example=null),
* @OA\Property(property="created_at", type="string", example="2025-07-01T10:00:00.000000Z")
* )
* )
* )
* ),
* @OA\Response(response=403, description="非參與方或預約狀態不符", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function listBookingMessages()
{
}
/**
* 發送預約訊息
*
* @OA\Post(
* path="/bookings/{booking}/messages",
* summary="發送預約訊息",
* description="向指定預約的對方發送文字訊息或圖片(multipart/form-data",
* operationId="sendBookingMessage",
* tags={"即時訊息"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="booking", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\MediaType(
* mediaType="multipart/form-data",
* @OA\Schema(
* @OA\Property(property="message", type="string", nullable=true, example="請問需要自備潛水裝備嗎?"),
* @OA\Property(property="image", type="string", format="binary", nullable=true, description="圖片(jpg/pngmessage 與 image 至少擇一)")
* )
* )
* ),
* @OA\Response(
* response=201,
* description="訊息已送出",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="id", type="integer", example=42),
* @OA\Property(property="booking_id", type="integer", example=12),
* @OA\Property(property="sender_type", type="string", example="member"),
* @OA\Property(property="message", type="string", nullable=true, example="請問需要自備潛水裝備嗎?"),
* @OA\Property(property="image_url", type="string", nullable=true, example=null),
* @OA\Property(property="read_at", type="string", nullable=true, example=null),
* @OA\Property(property="created_at", type="string", example="2025-07-01T10:05:00.000000Z")
* )
* )
* ),
* @OA\Response(response=403, description="非參與方或預約狀態不符", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="message 與 image 皆為空", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function sendBookingMessage()
{
}
/**
* 標記訊息為已讀
*
* @OA\Post(
* path="/bookings/{booking}/messages/read",
* summary="標記訊息為已讀",
* description="將指定預約中對方發送的所有未讀訊息批次標記為已讀",
* operationId="markMessagesRead",
* tags={"即時訊息"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="booking", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="標記成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="已標記為已讀")
* )
* ),
* @OA\Response(response=403, description="非參與方", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function markMessagesRead()
{
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Notifications (Member + Provider 共用) // Notifications (Member + Provider 共用)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
+141
View File
@@ -21,9 +21,150 @@ use OpenApi\Annotations as OA;
* name="教練預約", * name="教練預約",
* description="服務提供者的預約管理" * description="服務提供者的預約管理"
* ) * )
* @OA\Tag(
* name="教練驗證",
* description="服務提供者的資格驗證申請(證照送審)"
* )
*/ */
class ProviderApiDoc class ProviderApiDoc
{ {
// -----------------------------------------------------------------------
// Provider Verification (證照送審)
// -----------------------------------------------------------------------
/**
* 取得驗證申請狀態
*
* @OA\Get(
* path="/provider/verification",
* summary="取得驗證申請狀態",
* description="回傳當前服務提供者的驗證狀態與已上傳的證照清單",
* operationId="getProviderVerification",
* tags={"教練驗證"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="verification_status", type="string", enum={"unsubmitted","pending","approved","rejected"}, example="pending"),
* @OA\Property(property="rejection_reason", type="string", nullable=true, example=null),
* @OA\Property(
* property="certifications",
* type="array",
* @OA\Items(
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="url", type="string", example="http://localhost:8080/storage/providers/5/certifications/uuid.jpg")
* )
* )
* )
* )
* ),
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function getProviderVerification()
{
}
/**
* 上傳證照圖片
*
* @OA\Post(
* path="/provider/verification/certifications",
* summary="上傳證照圖片",
* description="上傳一張證照圖片(multipart/form-data),未送審前可多次上傳",
* operationId="uploadCertification",
* tags={"教練驗證"},
* security={{"bearerAuth": {}}},
* @OA\RequestBody(
* required=true,
* @OA\MediaType(
* mediaType="multipart/form-data",
* @OA\Schema(
* required={"certification"},
* @OA\Property(property="certification", type="string", format="binary", description="證照圖片(jpg/png,最大 5MB")
* )
* )
* ),
* @OA\Response(
* response=200,
* description="上傳成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="url", type="string", example="http://localhost:8080/storage/providers/5/certifications/uuid.jpg")
* )
* )
* ),
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function uploadCertification()
{
}
/**
* 刪除證照圖片
*
* @OA\Delete(
* path="/provider/verification/certifications/{id}",
* summary="刪除證照圖片",
* description="刪除指定的已上傳證照,已送審(pending/approved)狀態不可刪除",
* operationId="deleteCertification",
* tags={"教練驗證"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="證照 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="證照已刪除")
* )
* ),
* @OA\Response(response=403, description="無權限或當前狀態不可刪除", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="證照不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteCertification()
{
}
/**
* 送出驗證申請
*
* @OA\Post(
* path="/provider/verification/submit",
* summary="送出驗證申請",
* description="將驗證狀態由 unsubmitted/rejected 轉為 pending,至少需有一張已上傳的證照",
* operationId="submitVerification",
* tags={"教練驗證"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="送審成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="驗證申請已送出")
* )
* ),
* @OA\Response(response=422, description="尚未上傳證照或當前狀態不可送審", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function submitVerification()
{
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Provider Offers CRUD // Provider Offers CRUD
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Enums;
enum VerificationStatus: string
{
case Unsubmitted = 'unsubmitted';
case Pending = 'pending';
case Approved = 'approved';
case Rejected = 'rejected';
public const VALID_TRANSITIONS = [
'unsubmitted' => ['pending'],
'pending' => ['approved', 'rejected'],
'approved' => ['rejected'], // 撤銷驗證,原因必填
'rejected' => ['pending'], // 重新送審
];
public function canTransitionTo(self $newStatus): bool
{
return in_array($newStatus->value, self::VALID_TRANSITIONS[$this->value] ?? []);
}
}
@@ -131,20 +131,5 @@ class AdminUserController extends Controller
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]); return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]);
} }
public function toggleProviderVerified(int $id) // toggleProviderVerified 已移除:驗證狀態變更一律走 AdminVerificationController 的審核狀態機
{
if ($err = $this->checkAdmin()) return $err;
$user = $this->findUser($id, 'provider');
if (!$user) {
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
}
$profile = $user->providerProfile;
$profile->is_verified = !$profile->is_verified;
$profile->save();
$msg = $profile->is_verified ? '教練已驗證' : '已取消驗證';
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_verified' => $profile->is_verified]]);
}
} }
@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\VerificationStatus;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Notifications\ProviderVerificationApprovedNotification;
use App\Notifications\ProviderVerificationRejectedNotification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class AdminVerificationController extends Controller
{
public function index(Request $request)
{
$status = $request->query('status', VerificationStatus::Pending->value);
$query = User::where('role', 'provider')
->with(['providerProfile', 'providerCertifications'])
->whereHas('providerProfile');
if ($status !== 'all') {
$query->whereHas('providerProfile', fn($q) => $q->where('verification_status', $status));
}
$providers = $query->orderByDesc('updated_at')->get()->map(fn($u) => [
'user_id' => $u->id,
'name' => $u->name,
'email' => $u->email,
'business_name' => $u->providerProfile->business_name,
'verification_status' => $u->providerProfile->verification_status->value,
'rejection_reason' => $u->providerProfile->rejection_reason,
'certifications' => $u->providerCertifications->map(fn($c) => ['id' => $c->id, 'url' => $c->url])->values(),
]);
return response()->json(['status' => true, 'data' => $providers]);
}
public function approve(Request $request, int $userId)
{
$profile = $this->findProviderProfile($userId);
if (!$profile->verification_status->canTransitionTo(VerificationStatus::Approved)) {
return response()->json(['status' => false, 'message' => '當前狀態無法通過審核'], 422);
}
$profile->update(['verification_status' => VerificationStatus::Approved, 'rejection_reason' => null]);
// 驗證狀態影響公開課程可見性,需立即讓快取失效
Cache::tags(['diving_offers'])->flush();
$this->notify($profile->user, new ProviderVerificationApprovedNotification());
return response()->json(['status' => true, 'message' => '教練已通過審核']);
}
public function reject(Request $request, int $userId)
{
$data = $request->validate(['reason' => 'required|string|max:500']);
$profile = $this->findProviderProfile($userId);
if (!$profile->verification_status->canTransitionTo(VerificationStatus::Rejected)) {
return response()->json(['status' => false, 'message' => '當前狀態無法駁回'], 422);
}
$profile->update([
'verification_status' => VerificationStatus::Rejected,
'rejection_reason' => $data['reason'],
]);
Cache::tags(['diving_offers'])->flush();
$this->notify($profile->user, new ProviderVerificationRejectedNotification($data['reason']));
return response()->json(['status' => true, 'message' => '已駁回,教練將收到原因通知']);
}
private function findProviderProfile(int $userId)
{
return User::where('role', 'provider')->findOrFail($userId)->providerProfile()->firstOrFail();
}
private function notify(User $provider, object $notification): void
{
try {
$provider->notify($notification);
} catch (\Throwable $e) {
Log::error('ProviderVerification notification failed: ' . $e->getMessage());
}
}
}
+140 -74
View File
@@ -7,15 +7,19 @@ use App\Models\User;
use App\Models\AdminProfile; use App\Models\AdminProfile;
use App\Models\ProviderProfile; use App\Models\ProviderProfile;
use App\Models\MemberProfile; use App\Models\MemberProfile;
use App\Traits\NormalizesEmail;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
class AuthController extends Controller class AuthController extends Controller
{ {
// 科定規範角色 use NormalizesEmail;
private const ROLE_MEMBER = 'member'; private const ROLE_MEMBER = 'member';
private const ROLE_PROVIDER = 'provider'; private const ROLE_PROVIDER = 'provider';
private const ROLE_ADMIN = 'admin'; private const ROLE_ADMIN = 'admin';
@@ -131,6 +135,50 @@ class AuthController extends Controller
]); ]);
} }
private function isAccountLocked(string $role, string $email): bool
{
$count = Cache::get("login_failures:{$role}:{$email}", 0);
return $count >= config('auth_lockout.max_attempts');
}
private function recordLoginFailure(string $role, string $email): int
{
$ttl = config('auth_lockout.decay_minutes') * 60;
$countKey = "login_failures:{$role}:{$email}";
$expiresKey = "login_expires_at:{$role}:{$email}";
if (!Cache::has($countKey)) {
Cache::put($countKey, 1, $ttl);
Cache::put($expiresKey, now()->addSeconds($ttl)->toIso8601String(), $ttl);
return 1;
}
return Cache::increment($countKey);
}
private function clearLoginFailures(string $role, string $email): void
{
Cache::forget("login_failures:{$role}:{$email}");
Cache::forget("login_expires_at:{$role}:{$email}");
}
private function buildLockedResponse(string $role, string $email): \Illuminate\Http\JsonResponse
{
$decayMinutes = config('auth_lockout.decay_minutes');
$lockedUntil = Cache::get("login_expires_at:{$role}:{$email}");
if (!$lockedUntil) {
Log::warning("auth_lockout: expires_at key missing for {$role}:{$email}");
$lockedUntil = now()->addMinutes($decayMinutes)->toIso8601String();
}
return response()->json([
'status' => false,
'message' => "帳號已暫時鎖定,請於 {$decayMinutes} 分鐘後再試",
'locked_until' => $lockedUntil,
], 423);
}
/** /**
* 標準化的登出方法 * 標準化的登出方法
*/ */
@@ -268,7 +316,6 @@ class AuthController extends Controller
*/ */
public function loginMember(Request $request) public function loginMember(Request $request)
{ {
// 驗證請求數據
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'email' => 'required|string|email', 'email' => 'required|string|email',
'password' => 'required|string', 'password' => 'required|string',
@@ -282,20 +329,30 @@ class AuthController extends Controller
], 422); ], 422);
} }
// 檢查用戶是否存在 $email = $this->normalizeEmail($request->email);
$user = User::where('email', $request->email)
->where('role', 'member') // 只驗證會員
->first();
// 檢查密碼 // 1st: 帳號鎖定檢查
if ($this->isAccountLocked(self::ROLE_MEMBER, $email)) {
return $this->buildLockedResponse(self::ROLE_MEMBER, $email);
}
// 2nd: 帳號存在確認
$user = User::where('email', $email)->where('role', self::ROLE_MEMBER)->first();
// 3rd: 密碼驗證(帳號不存在與密碼錯誤回傳相同訊息)
if (!$user || !Hash::check($request->password, $user->password)) { if (!$user || !Hash::check($request->password, $user->password)) {
if ($user) {
$count = $this->recordLoginFailure(self::ROLE_MEMBER, $email);
if ($count >= config('auth_lockout.max_attempts')) {
return $this->buildLockedResponse(self::ROLE_MEMBER, $email);
}
}
return response()->json([ return response()->json([
'status' => false, 'status' => false,
'message' => '電子郵件或密碼錯誤' 'message' => '電子郵件或密碼錯誤'
], 401); ], 401);
} }
// 檢查用戶是否啟用
if (!$user->is_active) { if (!$user->is_active) {
return response()->json([ return response()->json([
'status' => false, 'status' => false,
@@ -303,10 +360,8 @@ class AuthController extends Controller
], 403); ], 403);
} }
// 創建 API token $this->clearLoginFailures(self::ROLE_MEMBER, $email);
$token = $user->createToken('auth_token')->plainTextToken; $token = $user->createToken('auth_token')->plainTextToken;
// 加載會員資料
$user->load('memberProfile'); $user->load('memberProfile');
return response()->json([ return response()->json([
@@ -531,7 +586,6 @@ class AuthController extends Controller
*/ */
public function loginProvider(Request $request) public function loginProvider(Request $request)
{ {
// 驗證請求數據
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'email' => 'required|string|email', 'email' => 'required|string|email',
'password' => 'required|string', 'password' => 'required|string',
@@ -545,20 +599,30 @@ class AuthController extends Controller
], 422); ], 422);
} }
// 檢查用戶是否存在 $email = $this->normalizeEmail($request->email);
$user = User::where('email', $request->email)
->where('role', 'provider') // 只驗證服務提供者
->first();
// 檢查密碼 // 1st: 帳號鎖定檢查
if ($this->isAccountLocked(self::ROLE_PROVIDER, $email)) {
return $this->buildLockedResponse(self::ROLE_PROVIDER, $email);
}
// 2nd: 帳號存在確認
$user = User::where('email', $email)->where('role', self::ROLE_PROVIDER)->first();
// 3rd: 密碼驗證(帳號不存在與密碼錯誤回傳相同訊息)
if (!$user || !Hash::check($request->password, $user->password)) { if (!$user || !Hash::check($request->password, $user->password)) {
if ($user) {
$count = $this->recordLoginFailure(self::ROLE_PROVIDER, $email);
if ($count >= config('auth_lockout.max_attempts')) {
return $this->buildLockedResponse(self::ROLE_PROVIDER, $email);
}
}
return response()->json([ return response()->json([
'status' => false, 'status' => false,
'message' => '電子郵件或密碼錯誤' 'message' => '電子郵件或密碼錯誤'
], 401); ], 401);
} }
// 檢查用戶是否啟用
if (!$user->is_active) { if (!$user->is_active) {
return response()->json([ return response()->json([
'status' => false, 'status' => false,
@@ -566,10 +630,8 @@ class AuthController extends Controller
], 403); ], 403);
} }
// 創建 API token $this->clearLoginFailures(self::ROLE_PROVIDER, $email);
$token = $user->createToken('auth_token')->plainTextToken; $token = $user->createToken('auth_token')->plainTextToken;
// 加載服務提供者資料
$user->load('providerProfile'); $user->load('providerProfile');
return response()->json([ return response()->json([
@@ -784,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);
}
/** /**
* 管理員登入 * 管理員登入
*/ */
@@ -1051,6 +1060,63 @@ class AuthController extends Controller
]); ]);
} }
/**
* 會員 Token Refresh
*/
public function refreshMember(Request $request): \Illuminate\Http\JsonResponse
{
$user = $request->user();
if ($user->role !== self::ROLE_MEMBER) {
return $this->getUnauthorizedResponse();
}
$request->user()->currentAccessToken()->delete();
$token = $user->createToken('auth_token')->plainTextToken;
return response()->json([
'status' => true,
'data' => ['token' => $token, 'token_type' => 'Bearer'],
]);
}
/**
* 服務提供者 Token Refresh
*/
public function refreshProvider(Request $request): \Illuminate\Http\JsonResponse
{
$user = $request->user();
if ($user->role !== self::ROLE_PROVIDER) {
return $this->getUnauthorizedResponse();
}
$request->user()->currentAccessToken()->delete();
$token = $user->createToken('auth_token')->plainTextToken;
return response()->json([
'status' => true,
'data' => ['token' => $token, 'token_type' => 'Bearer'],
]);
}
/**
* 管理員 Token Refresh
*/
public function refreshAdmin(Request $request): \Illuminate\Http\JsonResponse
{
$user = $request->user();
if ($user->role !== self::ROLE_ADMIN) {
return $this->getUnauthorizedResponse();
}
$request->user()->currentAccessToken()->delete();
$token = $user->createToken('auth_token')->plainTextToken;
return response()->json([
'status' => true,
'data' => ['token' => $token, 'token_type' => 'Bearer'],
]);
}
/** /**
* 查詢會員資料 * 查詢會員資料
* 只有管理員可以使用這個方法 * 只有管理員可以使用這個方法
@@ -5,15 +5,19 @@ namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\CourseImage; use App\Models\CourseImage;
use App\Models\DivingOffer; use App\Models\DivingOffer;
use App\Traits\CompressesImages;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
class CourseImageController extends Controller class CourseImageController extends Controller
{ {
use CompressesImages;
private function validateImage(Request $request): void private function validateImage(Request $request): void
{ {
$request->validate([ $request->validate([
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:2048', // 10MB:伺服器端會壓縮(compressToJpeg),放寬以容納手機原圖
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:10240',
]); ]);
} }
@@ -34,7 +38,7 @@ class CourseImageController extends Controller
Storage::disk('public')->delete($offer->cover_image); Storage::disk('public')->delete($offer->cover_image);
} }
$path = $request->file('image')->store("offers/{$offerId}/cover", 'public'); $path = $this->compressToJpeg($request->file('image'), "offers/{$offerId}/cover");
$offer->update(['cover_image' => $path]); $offer->update(['cover_image' => $path]);
return response()->json([ return response()->json([
@@ -67,7 +71,7 @@ class CourseImageController extends Controller
return response()->json(['status' => false, 'message' => '相簿最多 3 張圖片'], 422); return response()->json(['status' => false, 'message' => '相簿最多 3 張圖片'], 422);
} }
$path = $request->file('image')->store("offers/{$offerId}/gallery", 'public'); $path = $this->compressToJpeg($request->file('image'), "offers/{$offerId}/gallery");
$sortOrder = ($offer->courseImages()->max('sort_order') ?? 0) + 1; $sortOrder = ($offer->courseImages()->max('sort_order') ?? 0) + 1;
$image = CourseImage::create([ $image = CourseImage::create([
@@ -15,7 +15,7 @@ class DivingOfferController extends Controller
$cacheKey = 'diving_offers_' . md5(serialize($request->all())); $cacheKey = 'diving_offers_' . md5(serialize($request->all()));
$result = Cache::tags(['diving_offers'])->remember($cacheKey, 180, function () use ($request, $perPage) { $result = Cache::tags(['diving_offers'])->remember($cacheKey, 180, function () use ($request, $perPage) {
$query = DivingOffer::query(); $query = DivingOffer::query()->visibleToPublic();
if ($q = $request->query('q')) { if ($q = $request->query('q')) {
$query->where(function ($sub) use ($q) { $query->where(function ($sub) use ($q) {
@@ -55,7 +55,7 @@ class DivingOfferController extends Controller
public function show(int $id) public function show(int $id)
{ {
$offer = DivingOffer::with('courseImages')->find($id); $offer = DivingOffer::with('courseImages')->visibleToPublic()->find($id);
if (!$offer) { if (!$offer) {
return response()->json(['status' => false, 'message' => '課程不存在'], 404); return response()->json(['status' => false, 'message' => '課程不存在'], 404);
@@ -45,9 +45,15 @@ class MemberBookingController extends Controller
'notes' => 'nullable|string|max:500', 'notes' => 'nullable|string|max:500',
]); ]);
$schedule = CourseSchedule::with('divingOffer')->findOrFail($data['schedule_id']); $schedule = CourseSchedule::with('divingOffer.provider.providerProfile')->findOrFail($data['schedule_id']);
// Layer 1:快速失敗 // Layer 1:快速失敗
// 可見性繞過防護:課程教練未通過審核時不可建立新預約(既有預約不受影響,見 provider-verification 規格)
$offer = $schedule->divingOffer;
if ($offer->provider_id !== null && !($offer->provider?->providerProfile?->is_verified)) {
// is_verified 為 accessor= verification_status === approved
return response()->json(['status' => false, 'message' => '此課程目前不開放預約'], 422);
}
if ($schedule->status !== ScheduleStatus::Open) { if ($schedule->status !== ScheduleStatus::Open) {
return response()->json(['status' => false, 'message' => '此時段不開放預約'], 422); return response()->json(['status' => false, 'message' => '此時段不開放預約'], 422);
} }
@@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\VerificationStatus;
use App\Http\Controllers\Controller;
use App\Models\ProviderCertification;
use App\Traits\CompressesImages;
use Illuminate\Http\Request;
class ProviderVerificationController extends Controller
{
use CompressesImages;
private const MAX_CERTIFICATIONS = 3;
public function show(Request $request)
{
$profile = $request->user()->providerProfile;
return response()->json([
'status' => true,
'data' => [
'verification_status' => $profile->verification_status->value,
'rejection_reason' => $profile->rejection_reason,
'certifications' => $request->user()->providerCertifications
->map(fn($c) => ['id' => $c->id, 'url' => $c->url])->values(),
],
]);
}
public function uploadCertification(Request $request)
{
$request->validate([
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:10240',
]);
if ($err = $this->ensureCertificationsEditable($request)) {
return $err;
}
$user = $request->user();
if ($user->providerCertifications()->count() >= self::MAX_CERTIFICATIONS) {
return response()->json(['status' => false, 'message' => '證照最多 3 張'], 422);
}
$path = $this->compressToJpeg($request->file('image'), "providers/{$user->id}/certifications");
$certification = ProviderCertification::create([
'user_id' => $user->id,
'image_path' => $path,
]);
return response()->json([
'status' => true,
'message' => '證照已上傳',
'data' => ['id' => $certification->id, 'url' => $certification->url],
], 201);
}
public function deleteCertification(Request $request, int $id)
{
$certification = ProviderCertification::findOrFail($id);
if ($certification->user_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權刪除此證照'], 403);
}
if ($err = $this->ensureCertificationsEditable($request)) {
return $err;
}
$certification->delete();
return response()->json(['status' => true, 'message' => '證照已刪除']);
}
public function submit(Request $request)
{
$user = $request->user();
$profile = $user->providerProfile;
if (!$profile->verification_status->canTransitionTo(VerificationStatus::Pending)) {
return response()->json(['status' => false, 'message' => '當前狀態無法送審'], 422);
}
if ($user->providerCertifications()->count() < 1) {
return response()->json(['status' => false, 'message' => '請先上傳至少 1 張證照'], 422);
}
$profile->update([
'verification_status' => VerificationStatus::Pending,
'rejection_reason' => null,
]);
return response()->json(['status' => true, 'message' => '已送出審核,請等待平台審核結果']);
}
/**
* 證照僅於 unsubmitted / rejected 可增刪:pending / approved 是審核依據,不可變動
*/
private function ensureCertificationsEditable(Request $request)
{
$status = $request->user()->providerProfile->verification_status;
if (!in_array($status, [VerificationStatus::Unsubmitted, VerificationStatus::Rejected])) {
return response()->json(['status' => false, 'message' => '審核期間或通過後不可變更證照'], 422);
}
return null;
}
}
@@ -21,7 +21,7 @@ class ReviewController extends Controller
public function publicList(Request $request, int $offerId) public function publicList(Request $request, int $offerId)
{ {
$offer = DivingOffer::findOrFail($offerId); $offer = DivingOffer::visibleToPublic()->findOrFail($offerId);
$user = $request->user(); $user = $request->user();
$perPage = min((int) $request->query('per_page', 20), 50); $perPage = min((int) $request->query('per_page', 20), 50);
$sort = $request->query('sort', 'helpful'); $sort = $request->query('sort', 'helpful');
@@ -95,7 +95,7 @@ class ScheduleController extends Controller
public function publicList(int $offerId) public function publicList(int $offerId)
{ {
$offer = DivingOffer::findOrFail($offerId); $offer = DivingOffer::visibleToPublic()->findOrFail($offerId);
$schedules = CourseSchedule::where('diving_offer_id', $offer->id) $schedules = CourseSchedule::where('diving_offer_id', $offer->id)
->where('status', ScheduleStatus::Open->value) ->where('status', ScheduleStatus::Open->value)
->whereDate('scheduled_date', '>=', now()->toDateString()) ->whereDate('scheduled_date', '>=', now()->toDateString())
@@ -18,11 +18,19 @@ class SocialAuthController extends Controller
*/ */
public function redirectToGoogle() public function redirectToGoogle()
{ {
return Socialite::driver('google') $state = bin2hex(random_bytes(32));
session()->put('oauth_state', $state);
$redirect = Socialite::driver('google')
->scopes(['openid', 'profile', 'email']) ->scopes(['openid', 'profile', 'email'])
->with(['access_type' => 'offline', 'prompt' => 'consent']) // 這裡要求 prompt=consent 才能每次都獲取 refresh token ->with(['access_type' => 'offline', 'prompt' => 'consent', 'state' => $state])
->stateless()
->redirect(); ->redirect();
// Socialite 在 redirect() 內自己寫了一個 random state 進 session('state')。
// 用我們的 state 覆蓋,確保 user() 的內建驗證與 URL 中的 state 一致。
session()->put('state', $state);
return $redirect;
} }
/** /**
@@ -30,9 +38,16 @@ class SocialAuthController extends Controller
*/ */
public function handleGoogleCallback(Request $request) public function handleGoogleCallback(Request $request)
{ {
$requestState = $request->input('state');
$sessionState = session()->pull('oauth_state');
if (!$requestState || !$sessionState || !hash_equals($sessionState, $requestState)) {
return redirect(config('app.frontend_url') . '/login?error=oauth_failed');
}
try { try {
// 獲取 Google 用戶資訊 // 獲取 Google 用戶資訊Socialite 內建 state 驗證此時也會通過)
$googleUser = Socialite::driver('google')->stateless()->user(); $googleUser = Socialite::driver('google')->user();
// 查找相關的社交帳號 // 查找相關的社交帳號
$socialAccount = SocialAccount::where('provider', 'google') $socialAccount = SocialAccount::where('provider', 'google')
@@ -106,9 +121,9 @@ class SocialAuthController extends Controller
// 生成 Sanctum token // 生成 Sanctum token
$token = $user->createToken('google-auth')->plainTextToken; $token = $user->createToken('google-auth')->plainTextToken;
return redirect(env('FRONTEND_URL') . '/auth/callback?token=' . $token); return redirect(config('app.frontend_url') . '/auth/callback#token=' . $token);
} catch (\Exception $e) { } catch (\Exception $e) {
return redirect(env('FRONTEND_URL') . '/login?error=oauth_failed'); return redirect(config('app.frontend_url') . '/login?error=oauth_failed');
} }
} }
} }
+13
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -53,6 +54,18 @@ class DivingOffer extends Model
return $this->belongsTo(User::class, 'provider_id'); return $this->belongsTo(User::class, 'provider_id');
} }
/**
* 公開端點可見性:未通過審核(approved)教練的課程不對外曝光。
* 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('verification_status', \App\Enums\VerificationStatus::Approved->value));
});
}
public function schedules() public function schedules()
{ {
return $this->hasMany(CourseSchedule::class, 'diving_offer_id'); return $this->hasMany(CourseSchedule::class, 'diving_offer_id');
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class ProviderCertification extends Model
{
protected $fillable = [
'user_id',
'image_path',
];
protected static function booted(): void
{
static::deleting(function ($certification) {
Storage::disk('public')->delete($certification->image_path);
});
}
public function getUrlAttribute(): string
{
return Storage::disk('public')->url($this->image_path);
}
public function user()
{
return $this->belongsTo(User::class);
}
}
+21 -2
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Enums\VerificationStatus;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
/** /**
@@ -23,7 +24,9 @@ use Illuminate\Database\Eloquent\Model;
* @OA\Property(property="certifications", type="string", example="PADI五星級潛水中心,SSI認證中心", description="業者相關認證"), * @OA\Property(property="certifications", type="string", example="PADI五星級潛水中心,SSI認證中心", description="業者相關認證"),
* @OA\Property(property="facilities", type="string", example="空氣填充站,沖洗區,更衣室,休息區", description="設施"), * @OA\Property(property="facilities", type="string", example="空氣填充站,沖洗區,更衣室,休息區", description="設施"),
* @OA\Property(property="business_hours", type="string", example="週一至週五 09:00-18:00,週六日 08:00-19:00", description="營業時間"), * @OA\Property(property="business_hours", type="string", example="週一至週五 09:00-18:00,週六日 08:00-19:00", description="營業時間"),
* @OA\Property(property="is_verified", type="boolean", example=true, description="是否通過平台驗證"), * @OA\Property(property="verification_status", type="string", example="approved", description="審核狀態:unsubmitted / pending / approved / rejected"),
* @OA\Property(property="rejection_reason", type="string", nullable=true, example=null, description="駁回原因(rejected 時有值)"),
* @OA\Property(property="is_verified", type="boolean", example=true, description="是否通過平台驗證(相容欄位,= verification_status 為 approved"),
* @OA\Property(property="rating", type="number", format="float", example=4.8, description="評分"), * @OA\Property(property="rating", type="number", format="float", example=4.8, description="評分"),
* @OA\Property(property="website", type="string", example="https://www.bluedive.com", description="官方網站"), * @OA\Property(property="website", type="string", example="https://www.bluedive.com", description="官方網站"),
* @OA\Property(property="social_media", type="string", example="https://www.facebook.com/bluedive", description="社群媒體連結"), * @OA\Property(property="social_media", type="string", example="https://www.facebook.com/bluedive", description="社群媒體連結"),
@@ -55,7 +58,8 @@ class ProviderProfile extends Model
'certifications', 'certifications',
'facilities', 'facilities',
'business_hours', 'business_hours',
'is_verified', 'verification_status',
'rejection_reason',
'rating', 'rating',
'website', 'website',
'social_media', 'social_media',
@@ -64,6 +68,21 @@ class ProviderProfile extends Model
'is_active' 'is_active'
]; ];
protected $casts = [
'verification_status' => VerificationStatus::class,
];
/**
* API 相容層:既有前端與 Swagger 讀取 is_verified boolean
* 欄位移除後以 accessor 維持輸出(= 審核通過)
*/
protected $appends = ['is_verified'];
public function getIsVerifiedAttribute(): bool
{
return $this->verification_status === VerificationStatus::Approved;
}
/** /**
* 與用戶的關聯 * 與用戶的關聯
*/ */
+8
View File
@@ -140,6 +140,14 @@ class User extends Authenticatable
return $this->hasOne(ProviderProfile::class); return $this->hasOne(ProviderProfile::class);
} }
/**
* 教練驗證證照圖片(送審用)
*/
public function providerCertifications()
{
return $this->hasMany(ProviderCertification::class);
}
/** /**
* 獲取用戶的會員資料 * 獲取用戶的會員資料
*/ */
@@ -0,0 +1,42 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class ProviderVerificationApprovedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
return [
'type' => 'verification_approved',
'title' => '審核通過',
'body' => '恭喜!你的教練資格已通過平台審核,課程現在會公開曝光並可接受預約。',
'action_url' => config('app.frontend_url') . '/coach/dashboard',
'related_id' => $notifiable->id,
'related_type' => 'provider_verification',
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('教練資格審核通過 — CFDivePlatform')
->greeting('恭喜!')
->line('你的教練資格已通過平台審核。')
->line('你的課程現在會公開曝光,並可開始接受會員預約。')
->action('前往教練後台', config('app.frontend_url') . '/coach/dashboard');
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class ProviderVerificationRejectedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly string $reason) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
return [
'type' => 'verification_rejected',
'title' => '審核未通過',
'body' => "你的教練資格審核未通過。原因:{$this->reason}",
'action_url' => config('app.frontend_url') . '/coach/verification',
'related_id' => $notifiable->id,
'related_type' => 'provider_verification',
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('教練資格審核結果 — CFDivePlatform')
->greeting('審核結果通知')
->line('很抱歉,你的教練資格審核未通過。')
->line("原因:{$this->reason}")
->line('你可以補正資料與證照後重新送審。')
->action('重新送審', config('app.frontend_url') . '/coach/verification');
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Traits;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
trait CompressesImages
{
/**
* 壓縮上傳圖片並存入 public disk,回傳相對路徑。
* 參數與聊天圖片管線一致(scaleDown 2048 + JPEG quality 85),
* 確保平台內影像處理行為單一來源。
*/
private function compressToJpeg(UploadedFile $file, string $directory): string
{
$manager = new ImageManager(new Driver());
$image = $manager->read($file);
if ($image->width() > 2048 || $image->height() > 2048) {
$image->scaleDown(width: 2048, height: 2048);
}
$path = trim($directory, '/') . '/' . Str::uuid() . '.jpg';
Storage::disk('public')->put($path, $image->toJpeg(quality: 85));
return $path;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Traits;
trait NormalizesEmail
{
private function normalizeEmail(string $email): string
{
return strtolower(trim($email));
}
}
+1
View File
@@ -13,6 +13,7 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(at: ['*']);
$middleware->alias([ $middleware->alias([
'admin' => \App\Http\Middleware\EnsureAdmin::class, 'admin' => \App\Http\Middleware\EnsureAdmin::class,
]); ]);
+27
View File
@@ -0,0 +1,27 @@
services:
phpmyadmin:
image: phpmyadmin/phpmyadmin
container_name: cfdive-phpmyadmin
restart: unless-stopped
ports:
- "127.0.0.1:8081:80"
environment:
PMA_HOST: db
PMA_PORT: 3306
PMA_USER: ${DB_USERNAME:-cfdiveuser}
PMA_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
networks:
- cfdive-network
depends_on:
db:
condition: service_healthy
mailpit:
image: axllent/mailpit
container_name: cfdive-mailpit
restart: unless-stopped
ports:
- "127.0.0.1:8025:8025"
networks:
- cfdive-network
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
'max_attempts' => env('LOCKOUT_MAX_ATTEMPTS', 5),
'decay_minutes' => env('LOCKOUT_DECAY_MINUTES', 15),
];
+1 -1
View File
@@ -5,7 +5,7 @@ return [
'documentations' => [ 'documentations' => [
'default' => [ 'default' => [
'api' => [ 'api' => [
'title' => 'L5 Swagger UI', 'title' => 'CFDive Platform API',
], ],
'routes' => [ 'routes' => [
+1 -1
View File
@@ -47,7 +47,7 @@ return [
| |
*/ */
'expiration' => null, 'expiration' => 60 * 24 * 7,
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -19,7 +19,7 @@ return new class extends Migration
$table->timestamp('email_verified_at')->nullable(); // 驗證郵件時間 $table->timestamp('email_verified_at')->nullable(); // 驗證郵件時間
$table->string('password'); // 密碼 $table->string('password'); // 密碼
$table->string('phone')->nullable(); // 電話號碼,可為空 $table->string('phone')->nullable(); // 電話號碼,可為空
$table->enum('role', ['admin', 'coach', 'member'])->default('member'); // 角色:管理員、教練、會員 $table->enum('role', ['admin', 'coach', 'member', 'provider'])->default('member'); // 角色:管理員、教練、會員、服務提供者
$table->boolean('is_active')->default(true); // 是否啟用 $table->boolean('is_active')->default(true); // 是否啟用
$table->rememberToken(); // 記住我 token $table->rememberToken(); // 記住我 token
$table->timestamps(); // 建立與更新時間 $table->timestamps(); // 建立與更新時間
@@ -12,11 +12,17 @@ return new class extends Migration
*/ */
public function up(): void public function up(): void
{ {
if (DB::getDriverName() === 'sqlite') {
return;
}
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member', 'provider') NOT NULL DEFAULT 'member'"); DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member', 'provider') NOT NULL DEFAULT 'member'");
} }
public function down(): void public function down(): void
{ {
if (DB::getDriverName() === 'sqlite') {
return;
}
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member') NOT NULL DEFAULT 'member'"); DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member') NOT NULL DEFAULT 'member'");
} }
}; };
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('provider_profiles', function (Blueprint $table) {
$table->string('verification_status', 20)->default('unsubmitted')->after('is_verified')
->comment('教練驗證狀態:unsubmitted / pending / approved / rejected');
$table->text('rejection_reason')->nullable()->after('verification_status');
});
// 既有資料轉換:已驗證視為審核通過,未驗證回到未送審
DB::table('provider_profiles')->where('is_verified', true)->update(['verification_status' => 'approved']);
DB::table('provider_profiles')->where('is_verified', false)->update(['verification_status' => 'unsubmitted']);
Schema::table('provider_profiles', function (Blueprint $table) {
$table->dropColumn('is_verified');
});
}
public function down(): void
{
Schema::table('provider_profiles', function (Blueprint $table) {
$table->boolean('is_verified')->default(false);
});
DB::table('provider_profiles')->where('verification_status', 'approved')->update(['is_verified' => true]);
Schema::table('provider_profiles', function (Blueprint $table) {
$table->dropColumn(['verification_status', 'rejection_reason']);
});
}
};
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('provider_certifications', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('教練 User id');
$table->string('image_path')->comment('證照圖片相對路徑(public disk');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('provider_certifications');
}
};
+5 -5
View File
@@ -65,7 +65,7 @@ class DemoSeeder extends Seeder
'services' => '體驗潛水,OW認證課程,AOW認證課程,夜潛,水下攝影', 'services' => '體驗潛水,OW認證課程,AOW認證課程,夜潛,水下攝影',
'certifications' => 'PADI 五星級潛水中心,PADI Course Director', 'certifications' => 'PADI 五星級潛水中心,PADI Course Director',
'facilities' => '空氣填充站,氧氣填充站,裝備租借,沖洗區,更衣室,停車場', 'facilities' => '空氣填充站,氧氣填充站,裝備租借,沖洗區,更衣室,停車場',
'business_hours' => '每日 07:3018:00', 'is_verified' => true, 'business_hours' => '每日 07:3018:00', 'verification_status' => 'approved',
], ],
[ [
'email' => 'greendive@cfdive.com', 'name' => '陳美玲', 'email' => 'greendive@cfdive.com', 'name' => '陳美玲',
@@ -77,7 +77,7 @@ class DemoSeeder extends Seeder
'services' => '體驗潛水,OW認證課程,AOW認證課程,水下生態導覽,浮潛', 'services' => '體驗潛水,OW認證課程,AOW認證課程,水下生態導覽,浮潛',
'certifications' => 'PADI 潛水中心,SSI 認證教練', 'certifications' => 'PADI 潛水中心,SSI 認證教練',
'facilities' => '空氣填充站,裝備租借,防寒衣洗滌區,水下攝影記錄', 'facilities' => '空氣填充站,裝備租借,防寒衣洗滌區,水下攝影記錄',
'business_hours' => '每日 07:0017:30', 'is_verified' => true, 'business_hours' => '每日 07:0017:30', 'verification_status' => 'approved',
], ],
[ [
'email' => 'islet@cfdive.com', 'name' => '張大偉', 'email' => 'islet@cfdive.com', 'name' => '張大偉',
@@ -89,7 +89,7 @@ class DemoSeeder extends Seeder
'services' => '浮潛,體驗潛水,OW認證課程,海龜觀察導覽', 'services' => '浮潛,體驗潛水,OW認證課程,海龜觀察導覽',
'certifications' => 'PADI 授權潛水中心', 'certifications' => 'PADI 授權潛水中心',
'facilities' => '裝備租借,沖洗區,更衣室,代訂民宿服務', 'facilities' => '裝備租借,沖洗區,更衣室,代訂民宿服務',
'business_hours' => '每日 08:0017:00', 'is_verified' => true, 'business_hours' => '每日 08:0017:00', 'verification_status' => 'approved',
], ],
[ [
'email' => 'northdive@cfdive.com', 'name' => '王建國', 'email' => 'northdive@cfdive.com', 'name' => '王建國',
@@ -101,7 +101,7 @@ class DemoSeeder extends Seeder
'services' => '體驗潛水,OW認證課程,進階課程,Tec潛水,洞穴入門', 'services' => '體驗潛水,OW認證課程,進階課程,Tec潛水,洞穴入門',
'certifications' => 'PADI 課程總監,PADI TecRec 教練', 'certifications' => 'PADI 課程總監,PADI TecRec 教練',
'facilities' => '空氣填充站,混氣填充站,裝備租借,技術潛水裝備維修', 'facilities' => '空氣填充站,混氣填充站,裝備租借,技術潛水裝備維修',
'business_hours' => '週一公休,週二至週日 08:0018:00', 'is_verified' => false, 'business_hours' => '週一公休,週二至週日 08:0018:00', 'verification_status' => 'pending',
], ],
]; ];
@@ -123,7 +123,7 @@ class DemoSeeder extends Seeder
'certifications' => $data['certifications'], 'certifications' => $data['certifications'],
'facilities' => $data['facilities'], 'facilities' => $data['facilities'],
'business_hours' => $data['business_hours'], 'business_hours' => $data['business_hours'],
'is_verified' => $data['is_verified'], 'verification_status' => $data['verification_status'],
'rating' => 0, 'rating' => 0,
]); ]);
$providers[] = $user; $providers[] = $user;
+1 -1
View File
@@ -35,7 +35,7 @@ class DevelopmentSeeder extends Seeder
'description' => '專業 PADI 認證教練,10 年教學經驗', 'description' => '專業 PADI 認證教練,10 年教學經驗',
'contact_phone' => '0912345678', 'contact_phone' => '0912345678',
'contact_email' => 'coach@cfdive.com', 'contact_email' => 'coach@cfdive.com',
'is_verified' => true, 'verification_status' => 'approved',
]); ]);
// Member // Member
+26 -21
View File
@@ -38,11 +38,11 @@ services:
# 健康檢查配置 # 健康檢查配置
healthcheck: healthcheck:
test: ["CMD", "php", "-v"] # 檢查 PHP 版本確認服務正常 test: ["CMD-SHELL", "nc -z 127.0.0.1 9000 || exit 1"]
interval: 30s # 每30秒檢查一次 interval: 30s
timeout: 10s # 超時時間10秒 timeout: 10s
retries: 3 # 重試3次 retries: 3
start_period: 40s # 容器啟動後40秒開始檢查 start_period: 40s
nginx: nginx:
image: nginx:alpine image: nginx:alpine
@@ -60,7 +60,8 @@ services:
app: app:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"] # busybox wget 對 localhost 會先解析到 IPv6 ::1,但 nginx 只 listen IPv4,須用 127.0.0.1
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@@ -108,20 +109,26 @@ services:
ports: ports:
- "127.0.0.1:3306:3306" # 只綁 localhost,不對外 - "127.0.0.1:3306:3306" # 只綁 localhost,不對外
phpmyadmin:
image: phpmyadmin/phpmyadmin scheduler:
container_name: cfdive-phpmyadmin image: cfdive-platform
container_name: cfdive-scheduler
restart: unless-stopped restart: unless-stopped
working_dir: /var/www/
command: php artisan schedule:work
volumes:
- ./:/var/www
environment: environment:
PMA_HOST: db - DB_CONNECTION=mysql
PMA_PORT: 3306 - DB_HOST=db
PMA_USER: ${DB_USERNAME:-cfdiveuser} - DB_PORT=3306
PMA_PASSWORD: ${DB_PASSWORD} - DB_DATABASE=${DB_DATABASE:-CFDivePlatform}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} - DB_USERNAME=${DB_USERNAME:-cfdiveuser}
- DB_PASSWORD=${DB_PASSWORD}
networks: networks:
- cfdive-network - cfdive-network
depends_on: depends_on:
db: app:
condition: service_healthy condition: service_healthy
queue-worker: queue-worker:
@@ -145,12 +152,6 @@ services:
app: app:
condition: service_healthy condition: service_healthy
mailpit:
image: axllent/mailpit
container_name: cfdive-mailpit
restart: unless-stopped
networks:
- cfdive-network
redis: redis:
image: redis:alpine image: redis:alpine
@@ -158,6 +159,8 @@ services:
restart: unless-stopped restart: unless-stopped
networks: networks:
- cfdive-network - cfdive-network
volumes:
- redis-data:/data
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD", "redis-cli", "ping"]
interval: 30s interval: 30s
@@ -204,3 +207,5 @@ networks:
volumes: volumes:
mysql-data: mysql-data:
driver: local driver: local
redis-data:
driver: local
+5 -5
View File
@@ -22,9 +22,12 @@ php -r "
file_put_contents('/var/www/.env', \$env); file_put_contents('/var/www/.env', \$env);
" "
# Composer 依賴(vendor 不存在時才裝,通常已存在於 volume # Composer 依賴(vendor 不存在或 lock 內容變更時才裝)
if [ -f "composer.json" ] && { [ ! -d "vendor" ] || [ "composer.json" -nt "vendor/autoload.php" ]; }; then # 用內容比對而非 mtimegit checkout/pull 會更新 composer.json 的 mtime
# mtime 比對會讓每次分支操作後的開機都重跑 autoload 生成(bind mount 上耗時數分鐘、期間全站 502)
if [ -f "composer.lock" ] && { [ ! -d "vendor" ] || ! cmp -s composer.lock vendor/.composer.lock.installed; }; then
composer install --no-scripts --optimize-autoloader composer install --no-scripts --optimize-autoloader
cp composer.lock vendor/.composer.lock.installed
fi fi
# 背景執行:等 MySQL → migration → cache clear → storage link → swagger # 背景執行:等 MySQL → migration → cache clear → storage link → swagger
@@ -56,8 +59,5 @@ fi
echo "✅ [背景] 初始化完成" echo "✅ [背景] 初始化完成"
) & ) &
# 啟動 cronLaravel Scheduler
service cron start || cron || true
echo "🚀 啟動 php-fpm..." echo "🚀 啟動 php-fpm..."
exec "$@" exec "$@"
+11
View File
@@ -3,3 +3,14 @@ post_max_size=40M
memory_limit=512M memory_limit=512M
max_execution_time=600 max_execution_time=600
max_input_vars=10000 max_input_vars=10000
; ── OPcache 調校(2026-06-11 效能優化 O1.1)──
; 預設 revalidate_freq=2 在 Windows bind mount 上每 2 秒就重新 stat 全部
; 已快取腳本(~850 檔),實測使每次閒置後的請求從 0.23s 暴增至 2.5s。
; 拉長至 30 秒:程式碼變更最多 30 秒後生效;需要立即生效時執行
; docker compose exec app kill -USR2 1php-fpm 平滑重載,約 1 秒)
opcache.enable=1
opcache.memory_consumption=192
opcache.max_accelerated_files=20000
opcache.validate_timestamps=1
opcache.revalidate_freq=30
+271
View File
@@ -0,0 +1,271 @@
# CFDivePlatform 啟動指令
## 部署模式
專案根目錄有兩個 compose 檔:
| 檔案 | 用途 |
|------|------|
| `docker-compose.yml` | 正式服務(app、nginx、frontend、db、redis、reverb、queue-worker、scheduler |
| `compose.override.yml` | 開發工具(phpmyadmin、mailpit),已進版控 |
**本機 / VPS(預設)**Docker Compose 自動合併兩檔,開發工具一同啟動:
```bash
docker compose up -d
```
**雲端正式環境**:明確指定單一檔案,排除開發工具:
```bash
docker compose -f docker-compose.yml up -d
```
> VPS 首次部署或更新後執行 `git pull` 即可取得最新的 `compose.override.yml`,不需手動建立。
---
## 專案位置
```powershell
C:\laragon\www\CFDivePlatform
```
## 1. 進入專案目錄
```powershell
cd C:\laragon\www\CFDivePlatform
```
## 2. 準備 `.env`
如果尚未建立 `.env`
```powershell
copy .env.example .env
```
確認 `.env` 至少設定以下項目:
```env
APP_KEY=
DB_DATABASE=CFDivePlatform
DB_USERNAME=cfdiveuser
DB_PASSWORD=your_password
MYSQL_ROOT_PASSWORD=your_root_password
REVERB_APP_ID=your_reverb_app_id
REVERB_APP_KEY=your_reverb_app_key
REVERB_APP_SECRET=your_reverb_app_secret
VITE_REVERB_APP_KEY=your_reverb_app_key
```
如果 `APP_KEY` 是空的,可在容器啟動後執行:
```powershell
docker compose exec app php artisan key:generate
```
## 3. 啟動 Docker 服務
第一次啟動,或 Dockerfile / compose 設定有變更時:
```powershell
docker compose up -d --build
```
平常啟動:
```powershell
docker compose up -d
```
## 4. 查看服務狀態
```powershell
docker compose ps
```
查看所有服務 log
```powershell
docker compose logs -f
```
只查看 Laravel App log
```powershell
docker compose logs -f app
```
## 5. 初始化與維護指令
容器啟動時會自動執行部分初始化流程:
- 安裝 Composer 依賴
- 等待 MySQL 啟動
- 執行 migration
- 清除 Laravel cache
- 建立 storage link
- 產生 Swagger 文件
如需手動執行,可使用:
```powershell
docker compose exec app composer install
docker compose exec app php artisan migrate
docker compose exec app php artisan storage:link
docker compose exec app php artisan l5-swagger:generate
```
清除 Laravel 快取:
```powershell
docker compose exec app php artisan config:clear
docker compose exec app php artisan cache:clear
docker compose exec app php artisan route:clear
docker compose exec app php artisan view:clear
```
## 6. 建立管理員帳號
```powershell
docker compose exec app php artisan app:create-admin "Admin" "admin@example.com" --password="your_password"
```
密碼至少需要 8 碼。
## 7. 前端 Vite 開發模式
如果要使用本機 Vite 開發模式:
```powershell
npm install
npm run dev
```
正式建置:
```powershell
npm run build
```
Docker Compose 內也有 `frontend` 服務,預設網址為:
```text
http://localhost:5173
```
## 8. 重啟 frontend
如果 frontend 是透過 Docker Compose 啟動:
```powershell
docker compose restart frontend
```
如果有修改 frontend 的 Dockerfile、環境變數或 build 內容,建議重建:
```powershell
docker compose up -d --build frontend
```
如果是使用本機 Vite 開發模式,先在原本終端機按 `Ctrl + C` 停止,再重新執行:
```powershell
npm run dev
```
## 9. 服務網址
| 服務 | URL |
| --- | --- |
| API / Laravel + Nginx | <http://localhost:8080> |
| Frontend Docker | <http://localhost:5173> |
| phpMyAdmin | <http://localhost:8081> |
| Mailpit | <http://localhost:8025> |
| Reverb WebSocket | <ws://localhost:8085> |
| Health Check | <http://localhost:8080/health> |
## 10. Log 查看
Laravel 日誌走 stderr,透過 Docker 查看(不再有 `storage/logs/` 檔案):
```bash
# 即時串流
docker compose logs -f app
# 查看最後 50 行
docker compose logs app --tail=50
```
## 11. VPS 維護窗口操作(P1 部署)
PR merge 後 CI/CD 自動跑 composer/migrate/cache,完成後 SSH 進 VPS 執行:
```bash
cd /root/myproject/CFDivePlatform
# 1. 更新 .env(一次性)
sed -i 's/SESSION_DRIVER=database/SESSION_DRIVER=redis/' .env
sed -i 's/LOG_CHANNEL=stack/LOG_CHANNEL=stderr/' .env
sed -i '/^LOG_STACK=/d' .env
sed -i '/^LOG_DAILY_DAYS=/d' .env
# 2. Rebuild + 重啟(entrypoint 自動 config:clear
docker compose up -d --build
# 3. 驗證
docker compose ps # scheduler 應顯示 Up
docker compose logs app --tail=20 # 應有 stderr 日誌
docker compose exec redis redis-cli keys "laravel_session*" # 登入後應有 session key
docker compose exec app sh -c "which cron 2>/dev/null || echo 'cron binary not found'" # 應輸出 not found
```
**Rollback(若需還原):**
Session/Log 還原(不需 rebuild):
```bash
sed -i 's/SESSION_DRIVER=redis/SESSION_DRIVER=database/' .env
sed -i 's/LOG_CHANNEL=stderr/LOG_CHANNEL=stack/' .env
echo "LOG_STACK=daily" >> .env
echo "LOG_DAILY_DAYS=14" >> .env
docker compose exec app php artisan config:clear
docker compose restart app
```
Scheduler 還原(需 rebuild):
```bash
git revert <commit-hash>
docker compose up -d --build
```
## 12. 停止服務
停止容器,但保留資料庫 volume
```powershell
docker compose down
```
停止容器並移除 volume,會清除 MySQL 資料:
```powershell
docker compose down -v
```
## 最短啟動流程
```powershell
cd C:\laragon\www\CFDivePlatform
copy .env.example .env
docker compose up -d --build
docker compose exec app php artisan key:generate
docker compose ps
```
啟動完成後開啟:
```text
http://localhost:8080
```
@@ -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/CDGitea 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 部署驗收 — 可行性:高
程式碼已 mergecommit 5ba598c 後系列),記憶中部署狀態未確認。剩餘工作為運維執行:rebuild frontend、restart reverb、`.env` 五個值(細節在既有部署清單中)。建議排入下次部署窗口一次完成,避免長期掛著「已完成未交付」狀態。
### 2.4 CI/CDGitea 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 審核 / 推薦系統**:資料量不足,無訓練與評估基礎。
@@ -0,0 +1,69 @@
# CFDivePlatform 操作體驗優化計畫
> 撰寫日期:2026-06-11
> 背景:使用者反映本機操作體驗差、回應慢。本文以實測數據定位根因,並依投資報酬率排序優化項目。
> **狀態更新(2026-06-11**:Phase 1 已執行完成,穩態延遲 2.5s → **0.2s**(約 10 倍改善)。執行結果見第三節,根因分析已依實測修正。
---
## 一、實測基準(2026-06-11 上午,修復前)
| 量測項目 | 數值 | 備註 |
|---------|------|------|
| `GET /api/diving-offers`(冷) | **7.98 秒** | 經 nginx:8080 → php-fpm |
| 同端點(Redis 快取命中、間隔數秒) | **2.3~2.5 秒** | 快取命中仍要 2.5 秒 → 瓶頸不在查詢 |
| 同端點(2 秒內連發) | 0.23 秒 | 關鍵線索:短窗口內其實很快 |
| 容器內全測試套件 | 64.4 秒 | 同套件本機 PHP 僅 4.5 秒 |
## 二、根因分析(依實測修正)
> ⚠️ 初版文檔判定「OPcache 被停用」是**錯誤診斷**——當時用 `php -r`CLI)量測,而 `opcache.enable_cli` 預設關閉造成假象。php-fpm 的 OPcache 一直是啟用的(851 個腳本在快取中)。正確根因如下:
1. **`opcache.revalidate_freq=2`(預設值)× Windows bind mount**:每隔 2 秒,下一個請求就要跨 Windows↔Linux 檔案系統邊界重新 stat 全部 ~850 個已快取腳本。實測鐵證:2 秒窗口內 0.23s,窗口過期後 2.3~2.5s。一般操作節奏(點一下、看一下、再點)幾乎每次都踩在窗口外,所以「每個操作都慢」。
2. **Entrypoint 的 composer 重裝條件用 mtime 比對**`composer.json -nt vendor/autoload.php`——git checkout/pull 會更新 composer.json 的 mtime,導致每次分支操作後的容器啟動都重跑 `composer install`bind mount 上 autoload 生成耗時數分鐘,**期間 php-fpm 未啟動、全站 502**)。
3. **nginx healthcheck 誤報**busybox wget 對 `localhost` 先解析 IPv6 `::1`nginx 只 listen IPv4 → 永遠 connection refused → 永遠 unhealthy。
4. 觀察項(未處理):cron 每分鐘 `schedule:run` 以 CLI 啟動 PHPCLI 無 OPcache + bind mount),造成偶發 ~2 秒突波;`SESSION/QUEUE=database``APP_DEBUG=true`
## 三、Phase 1 執行紀錄(2026-06-11 完成,branch perf/phase1-opcache
| 項目 | 修改 | 結果 |
|------|------|------|
| O1.1 OPcache 調校 | `docker/php/local.ini``revalidate_freq` 2→**30**、memory 128→192、max_files 10000→20000 | 穩態 **0.20~0.27s**(前:2.3~2.5s);每 30 秒閒置後僅一次 ~1.2s revalidate |
| O1.2 healthcheck 修復 | docker-compose.yml`http://localhost/``http://127.0.0.1/` | nginx 顯示 **healthy** |
| O1.3 composer 重裝條件 | entrypoint 改為 composer.lock **內容比對**`cmp` vs `vendor/.composer.lock.installed` 標記檔) | git 分支操作不再觸發開機重裝與 502 窗口 |
### 修復後量測(同一端點、每 5 秒一次)
```
#1: 1.96scron 突波) #2: 0.22s #3: 0.21s #4: 0.20s #5: 0.27s #6: 0.26s
閒置 35 秒後:1.19srevalidate,每 30 秒至多一次)→ 下一次 0.30s
```
### 開發注意事項
- 後端程式碼變更最多 **30 秒**後生效;要立即生效:`docker compose exec app kill -USR2 1`(php-fpm 平滑重載,約 1 秒)
- `local.ini` 與 entrypoint 是 build 進 image 的,改動需 `docker compose build app && docker compose up -d app`
- 此設定對 VPS 同樣適用且有益(Linux 原生 stat 便宜,revalidate=30 純賺)
## 四、後續階段(依新數據重新評估)
### Phase 2 — 結構性(暫緩)
原計畫的 vendor named volume / WSL2 遷移以「Phase 1 後仍 >1s」為觸發條件——實測穩態 0.2s,**未達觸發條件,暫緩**。若未來 cron 突波造成困擾,優先處理觀察項 4(如 schedule:run 改由長駐 worker、或縮減無任務時的 bootstrap)。
### Phase 3 — 應用層體感(與環境無關,部署後使用者也受益,1~2 天)
**O3.1 課程封面圖未壓縮**:聊天圖片已有 `scaleDown(2048) + toJpeg(85)``BookingMessageController.php:115`),但 `CourseImageController` 原檔直存。建議套用相同縮圖管線 + 前端 `loading="lazy"`。應開 openspec change(影響 `course-image-upload` 規格)。
**O3.2 無分頁的列表端點**`/api/member/bookings``/api/provider/bookings``/api/admin/bookings``->get()` 全量。資料量成長後線性變慢,建議金流上線前補分頁。
**O3.3 前端載入體感**:列表頁 skeleton 佔位、搜尋 debounce300ms)。路由已全面 lazy-load(已驗證,不需處理)。
### 生產環境註記(部署時順手做)
- `php artisan optimize`config / route / view cache
- 生產 OPcache 可進一步 `validate_timestamps=0`(部署時 `kill -USR2 1` 重載刷新)
## 五、驗收方式
每步以同一 curl 量測對照(`curl -s -o /dev/null -w "%{time_total}" http://127.0.0.1:8080/api/diving-offers`),數據寫回本文件。
@@ -0,0 +1,84 @@
# CFDivePlatform 規格與實作分析報告暨修補紀錄
> 分析日期:2026-06-11
> 分析範圍:`openspec/specs/`(32 份規格)vs 後端實作(Laravel 11)、前端(`frontend/` Vue 3)、測試(`tests/`
> 分析基準:branch `test/auth-tests-coverage`commit ca5b843
> **狀態更新(2026-06-11**:修補計畫四階段已全部完成於 branch `fix/audit-remediation`PR #28),本文件保留發現紀錄與執行結果,未完成事項見第四節。
---
## 一、現況總覽
### 1.1 規格狀態
- OpenSpec 規格共 **32 份**(修補後 33 份,新增 `provider-verification`),全部 change 已歸檔。
- 涵蓋範圍:認證安全(lockout / rate-limiting / OAuth state / token refresh)、三角色入口(Member / Coach / Admin)、預約生命週期、評價系統、通知系統、即時聊天、Swagger 文件、效能優化。
### 1.2 實作狀態(修補後)
| 模組 | 規格 | 實作 | 測試 |
|------|------|------|------|
| 認證(三角色登入/註冊/登出) | ✅ | ✅ | ✅ |
| 帳號鎖定 + OAuth state | ✅ | ✅ | ✅ |
| Token Refresh | ✅ | ✅ | ✅ |
| 課程 CRUD + 圖片上傳 | ✅ | ✅ | ✅ |
| 預約生命週期(七狀態機、防超賣、Scheduler) | ✅ | ✅ | ✅(修補新增 26 案例) |
| 評價系統 | ✅ | ✅ | ✅ |
| 通知系統 | ✅ | ✅ | ⚠️ 僅 Scheduler 路徑覆蓋 |
| 即時聊天 + Presence | ✅ | ✅ | ✅(授權路徑,修補新增 8 案例) |
| Admin 管理端點 | ✅ | ✅ | ✅(權限邊界,修補新增 5 案例) |
| 教練驗證(is_verified 最小語意) | ✅(修補新增) | ✅ | ✅(7 案例) |
| 教練審核流程(完整版) | ❌ 無規格 | ❌ | — |
| 金流整合 | ❌ 無規格 | ❌ | — |
---
## 二、稽核發現(保留原始紀錄,狀態見第三節)
### 🔴 P0-1`POST /api/admin/register` 完全公開,任何人可註冊管理員帳號
- 該路由無任何 middleware、無邀請碼;request 通過基本驗證後直接 `role => 'admin'` 建立帳號。攻擊者一個 HTTP 請求即可取得全平台管理權限。`admin-auth` 規格從未定義此端點(規格外實作)。
### 🟠 P1-1`is_verified`(教練驗證)從未被任何業務邏輯強制執行
- `is_verified` 只有 Admin toggle 開關與 Model 屬性,教練註冊後不需任何審核即可上架、曝光、接單。無任何規格定義其業務語意。
### 🟠 P1-2:登入頻率限制規格與實作不一致(規格漂移)
- 規格寫 Member/Provider 5 次/分鐘,實作 `throttle:10,1`,測試(commit 0dabc4e)已配合實作改為 10 次,規格未同步。
### 🟡 P2-1:核心業務流程(預約)零測試覆蓋
- 預約狀態機、防超賣鎖定、Scheduler 自動過期/完成沒有任何測試;時段管理、通知觸發、聊天授權、Admin 端點亦無。
### 🟡 P2-2:規格維護債
- `auth-test-coverage` Purpose 為 TBD`member-portal-ui` repo 描述過時;`admin-auth` 未涵蓋 check-member / check-provider 端點。
---
## 三、修補執行紀錄(2026-06-11 全部完成)
分支 `fix/audit-remediation`(基底 `test/auth-tests-coverage`PR #28),容器內全套件 **146 passed / 378 assertions**
| 對應發現 | 修補內容 | Commit |
|---------|---------|--------|
| P0-1 | 移除公開 admin/register(路由/controller/Swagger),新增 `php artisan app:create-admin`min:8),`AdminAccountCreationTest` 4 案例,`admin-auth` 規格補「帳號建立途徑」 | aeb8c97 |
| P1-2 | `login-rate-limiting` 規格同步至 10/min(以實作為準,註記放寬理由) | 88a81aa |
| P1-1 | `DivingOffer::visibleToPublic` scope(公開 index/show 過濾未驗證教練課程)、toggle-verified 後 flush 快取、新增 `provider-verification` 規格、`DivingOfferVisibilityTest` 7 案例 | 3c38d08 |
| P2-1 | 預約核心 39 案例:狀態機 17、防超賣 3、Scheduler 6、聊天授權 8、Admin 權限邊界 5 | 63b25f9 |
| P2-2 | 規格清理(Purpose / repo 描述 / 查詢端點)+ 同步移除測已刪端點的 2 個舊測試 | d3a0d30, cec2078 |
| — | OpenSpec 補歸檔 `2026-06-11-audit-remediation`proposal / design / tasks / 規格增量) | a97a7d0 |
**與原計畫的偏差**:原計畫建議「Demo seeder 將教練預設 `is_verified=true`」,實際保留 seeder 中 1 位未驗證教練——其課程從公開列表消失正好展示新規則,屬刻意決策(記錄於 PR #28 說明)。
## 四、尚未處理事項
1. **provider-verification 已知限制**(規格 Notes 已載明):`GET /api/diving-offers/{id}/schedules``GET /api/diving-offers/{id}/reviews` 與預約建立流程尚未套用可見性過濾,知道課程 id 仍可間接互動。
**已開 OpenSpec change `provider-verification-gaps` 規劃**`openspec/changes/provider-verification-gaps/`),待實作。
2. **操作體驗優化**:見 `docs/analysis/2026-06-11-performance-optimization-plan.md`O1.1 啟用 OPcache 起步),尚未實施。
3. **新功能路線圖**:教練審核完整版、金流整合等,見 `docs/analysis/2026-06-11-future-roadmap-feasibility.md`
## 五、規格同步狀態聲明
修補已同步的規格:`admin-auth``login-rate-limiting``auth-test-coverage``member-portal-ui`、新增 `provider-verification`;變更軌跡歸檔於 `openspec/changes/archive/2026-06-11-audit-remediation/`
+66 -1
View File
@@ -6,11 +6,76 @@ const api = axios.create({
}) })
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
const token = localStorage.getItem('token') const token = sessionStorage.getItem('token')
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`
} }
return config return config
}) })
let isRefreshing = false
let isRedirecting = false
let pendingRequests = []
function resolvePending(token) {
pendingRequests.forEach((cb) => cb(token))
pendingRequests = []
}
function rejectPending(error) {
pendingRequests.forEach((cb) => cb(null, error))
pendingRequests = []
}
api.interceptors.response.use(
(response) => response,
async (error) => {
const config = error.config
const status = error.response?.status
const isAuthEndpoint =
config.url.includes('/login') ||
config.url.includes('/register') ||
config.url.includes('/refresh')
if (status !== 401 || isAuthEndpoint || config._retry) {
return Promise.reject(error)
}
if (isRefreshing) {
return new Promise((resolve, reject) => {
pendingRequests.push((token, err) => {
if (err) return reject(err)
config.headers.Authorization = `Bearer ${token}`
resolve(api(config))
})
})
}
config._retry = true
isRefreshing = true
try {
const { data } = await api.post('/member/refresh')
const newToken = data.data.token
sessionStorage.setItem('token', newToken)
api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`
resolvePending(newToken)
config.headers.Authorization = `Bearer ${newToken}`
return api(config)
} catch {
rejectPending(error)
if (!isRedirecting) {
isRedirecting = true
sessionStorage.removeItem('token')
sessionStorage.removeItem('user')
window.location.href = '/login'
}
return Promise.reject(error)
} finally {
isRefreshing = false
}
}
)
export default api export default api
+66 -1
View File
@@ -6,11 +6,76 @@ const coachApi = axios.create({
}) })
coachApi.interceptors.request.use((config) => { coachApi.interceptors.request.use((config) => {
const token = localStorage.getItem('coach_token') const token = sessionStorage.getItem('coach_token')
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`
} }
return config return config
}) })
let isRefreshing = false
let isRedirecting = false
let pendingRequests = []
function resolvePending(token) {
pendingRequests.forEach((cb) => cb(token))
pendingRequests = []
}
function rejectPending(error) {
pendingRequests.forEach((cb) => cb(null, error))
pendingRequests = []
}
coachApi.interceptors.response.use(
(response) => response,
async (error) => {
const config = error.config
const status = error.response?.status
const isAuthEndpoint =
config.url.includes('/login') ||
config.url.includes('/register') ||
config.url.includes('/refresh')
if (status !== 401 || isAuthEndpoint || config._retry) {
return Promise.reject(error)
}
if (isRefreshing) {
return new Promise((resolve, reject) => {
pendingRequests.push((token, err) => {
if (err) return reject(err)
config.headers.Authorization = `Bearer ${token}`
resolve(coachApi(config))
})
})
}
config._retry = true
isRefreshing = true
try {
const { data } = await coachApi.post('/provider/refresh')
const newToken = data.data.token
sessionStorage.setItem('coach_token', newToken)
coachApi.defaults.headers.common['Authorization'] = `Bearer ${newToken}`
resolvePending(newToken)
config.headers.Authorization = `Bearer ${newToken}`
return coachApi(config)
} catch {
rejectPending(error)
if (!isRedirecting) {
isRedirecting = true
sessionStorage.removeItem('coach_token')
sessionStorage.removeItem('coach_user')
window.location.href = '/coach/login'
}
return Promise.reject(error)
} finally {
isRefreshing = false
}
}
)
export default coachApi export default coachApi
+2 -2
View File
@@ -10,8 +10,8 @@ notificationApi.interceptors.request.use((config) => {
// 兩者都存在時(測試情境),以當前頁面路徑決定:/coach 開頭用 coach_token,其餘用 token // 兩者都存在時(測試情境),以當前頁面路徑決定:/coach 開頭用 coach_token,其餘用 token
const isCoachPage = window.location.pathname.startsWith('/coach') const isCoachPage = window.location.pathname.startsWith('/coach')
const token = isCoachPage const token = isCoachPage
? (localStorage.getItem('coach_token') || localStorage.getItem('token')) ? (sessionStorage.getItem('coach_token') || sessionStorage.getItem('token'))
: (localStorage.getItem('token') || localStorage.getItem('coach_token')) : (sessionStorage.getItem('token') || sessionStorage.getItem('coach_token'))
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`
} }
+1
View File
@@ -23,6 +23,7 @@ async function handleLogout() {
<RouterLink to="/coach/schedules" class="text-sm hover:text-gray-300 transition">時段管理</RouterLink> <RouterLink to="/coach/schedules" class="text-sm hover:text-gray-300 transition">時段管理</RouterLink>
<RouterLink to="/coach/bookings" class="text-sm hover:text-gray-300 transition">預約管理</RouterLink> <RouterLink to="/coach/bookings" class="text-sm hover:text-gray-300 transition">預約管理</RouterLink>
<RouterLink to="/coach/reviews" class="text-sm hover:text-gray-300 transition">課程評價</RouterLink> <RouterLink to="/coach/reviews" class="text-sm hover:text-gray-300 transition">課程評價</RouterLink>
<RouterLink to="/coach/verification" class="text-sm hover:text-gray-300 transition">驗證申請</RouterLink>
<RouterLink to="/coach/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink> <RouterLink to="/coach/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink>
</div> </div>
+1
View File
@@ -14,6 +14,7 @@ defineProps({
v-if="offer.cover_image_url" v-if="offer.cover_image_url"
:src="offer.cover_image_url" :src="offer.cover_image_url"
:alt="offer.title" :alt="offer.title"
loading="lazy"
class="w-full h-full object-cover" class="w-full h-full object-cover"
/> />
<div v-else class="bg-gradient-to-br from-ocean-700 to-ocean-500 h-full flex items-center justify-center text-white text-5xl"> <div v-else class="bg-gradient-to-br from-ocean-700 to-ocean-500 h-full flex items-center justify-center text-white text-5xl">
+1 -1
View File
@@ -4,7 +4,7 @@ import Pusher from 'pusher-js'
window.Pusher = Pusher window.Pusher = Pusher
function getAuthToken() { function getAuthToken() {
return localStorage.getItem('coach_token') || localStorage.getItem('token') || null return sessionStorage.getItem('coach_token') || sessionStorage.getItem('token') || null
} }
const echo = new Echo({ const echo = new Echo({
+1
View File
@@ -30,6 +30,7 @@ const routes = [
{ path: 'schedules', component: () => import('../views/coach/ScheduleManagerView.vue') }, { path: 'schedules', component: () => import('../views/coach/ScheduleManagerView.vue') },
{ path: 'bookings', component: () => import('../views/coach/BookingManagerView.vue') }, { path: 'bookings', component: () => import('../views/coach/BookingManagerView.vue') },
{ path: 'reviews', component: () => import('../views/coach/ReviewsView.vue') }, { path: 'reviews', component: () => import('../views/coach/ReviewsView.vue') },
{ path: 'verification', component: () => import('../views/coach/VerificationView.vue') },
], ],
}, },
+6 -6
View File
@@ -11,8 +11,8 @@ export const useAuthStore = defineStore('auth', () => {
const isLoggedIn = computed(() => !!token.value) const isLoggedIn = computed(() => !!token.value)
function init() { function init() {
const saved = localStorage.getItem('token') const saved = sessionStorage.getItem('token')
const savedUser = localStorage.getItem('user') const savedUser = sessionStorage.getItem('user')
if (saved) { if (saved) {
token.value = saved token.value = saved
user.value = savedUser ? JSON.parse(savedUser) : null user.value = savedUser ? JSON.parse(savedUser) : null
@@ -25,8 +25,8 @@ export const useAuthStore = defineStore('auth', () => {
function setAuth(userData, tokenValue) { function setAuth(userData, tokenValue) {
user.value = userData user.value = userData
token.value = tokenValue token.value = tokenValue
localStorage.setItem('token', tokenValue) sessionStorage.setItem('token', tokenValue)
localStorage.setItem('user', JSON.stringify(userData)) sessionStorage.setItem('user', JSON.stringify(userData))
const ns = useNotificationStore() const ns = useNotificationStore()
ns.startPolling() ns.startPolling()
updateEchoToken() updateEchoToken()
@@ -42,8 +42,8 @@ export const useAuthStore = defineStore('auth', () => {
ns.stopPolling() ns.stopPolling()
user.value = null user.value = null
token.value = null token.value = null
localStorage.removeItem('token') sessionStorage.removeItem('token')
localStorage.removeItem('user') sessionStorage.removeItem('user')
} }
return { user, token, isLoggedIn, init, setAuth, logout } return { user, token, isLoggedIn, init, setAuth, logout }
+6 -6
View File
@@ -11,8 +11,8 @@ export const useCoachAuthStore = defineStore('coachAuth', () => {
const isLoggedIn = computed(() => !!token.value) const isLoggedIn = computed(() => !!token.value)
function init() { function init() {
const savedToken = localStorage.getItem('coach_token') const savedToken = sessionStorage.getItem('coach_token')
const savedUser = localStorage.getItem('coach_user') const savedUser = sessionStorage.getItem('coach_user')
if (savedToken) { if (savedToken) {
token.value = savedToken token.value = savedToken
user.value = savedUser ? JSON.parse(savedUser) : null user.value = savedUser ? JSON.parse(savedUser) : null
@@ -25,8 +25,8 @@ export const useCoachAuthStore = defineStore('coachAuth', () => {
function setAuth(userData, tokenValue) { function setAuth(userData, tokenValue) {
user.value = userData user.value = userData
token.value = tokenValue token.value = tokenValue
localStorage.setItem('coach_token', tokenValue) sessionStorage.setItem('coach_token', tokenValue)
localStorage.setItem('coach_user', JSON.stringify(userData)) sessionStorage.setItem('coach_user', JSON.stringify(userData))
const ns = useNotificationStore() const ns = useNotificationStore()
ns.startPolling() ns.startPolling()
updateEchoToken() updateEchoToken()
@@ -42,8 +42,8 @@ export const useCoachAuthStore = defineStore('coachAuth', () => {
ns.stopPolling() ns.stopPolling()
user.value = null user.value = null
token.value = null token.value = null
localStorage.removeItem('coach_token') sessionStorage.removeItem('coach_token')
localStorage.removeItem('coach_user') sessionStorage.removeItem('coach_user')
} }
return { user, token, isLoggedIn, init, setAuth, logout } return { user, token, isLoggedIn, init, setAuth, logout }
+2
View File
@@ -25,6 +25,7 @@ export const useNotificationStore = defineStore('notifications', () => {
} }
} }
} catch (e) { } catch (e) {
if (e?.response?.status === 401) { stopPolling(); return }
console.error('[NotificationStore] fetchUnreadCount failed:', e?.response?.status, e?.message) console.error('[NotificationStore] fetchUnreadCount failed:', e?.response?.status, e?.message)
} }
} }
@@ -35,6 +36,7 @@ export const useNotificationStore = defineStore('notifications', () => {
notifications.value = res.data.data notifications.value = res.data.data
unreadCount.value = res.data.unread_count unreadCount.value = res.data.unread_count
} catch (e) { } catch (e) {
if (e?.response?.status === 401) { stopPolling(); return }
console.error('[NotificationStore] fetchNotifications failed:', e?.response?.status, e?.message) console.error('[NotificationStore] fetchNotifications failed:', e?.response?.status, e?.message)
} }
} }
+2 -2
View File
@@ -9,7 +9,7 @@ const route = useRoute()
const auth = useAuthStore() const auth = useAuthStore()
onMounted(async () => { onMounted(async () => {
const token = route.query.token const token = new URLSearchParams(window.location.hash.substring(1)).get('token')
const error = route.query.error const error = route.query.error
if (error || !token) { if (error || !token) {
@@ -18,7 +18,7 @@ onMounted(async () => {
} }
// 存 token 先,再拉 profile // 存 token 先,再拉 profile
localStorage.setItem('token', token) sessionStorage.setItem('token', token)
try { try {
const res = await api.get('/member/profile') const res = await api.get('/member/profile')
auth.setAuth(res.data.data, token) auth.setAuth(res.data.data, token)
+1 -1
View File
@@ -147,7 +147,7 @@ async function submitBooking() {
target="_blank" target="_blank"
class="w-24 h-20 rounded-xl overflow-hidden shrink-0 border-2 border-transparent hover:border-ocean-400 transition" class="w-24 h-20 rounded-xl overflow-hidden shrink-0 border-2 border-transparent hover:border-ocean-400 transition"
> >
<img :src="img.url" class="w-full h-full object-cover" /> <img :src="img.url" loading="lazy" class="w-full h-full object-cover" />
</a> </a>
</div> </div>
+1 -1
View File
@@ -14,7 +14,7 @@ const error = ref('')
const loading = ref(false) const loading = ref(false)
const oauthError = route.query.error === 'oauth_failed' const oauthError = route.query.error === 'oauth_failed'
? 'Google 登入失敗,請重試' ? 'OAuth 授權失敗,請重新嘗試'
: '' : ''
const successMsg = route.query.registered ? '註冊成功,請登入。' : '' const successMsg = route.query.registered ? '註冊成功,請登入。' : ''
+94 -12
View File
@@ -5,6 +5,21 @@ import adminApi from '../../api/adminAxios'
const providers = ref([]) const providers = ref([])
const loading = ref(true) const loading = ref(true)
const search = ref('') const search = ref('')
const pendingOnly = ref(false)
const certsOf = ref(null) // { name, certifications } 查看證照面板
const rejectTarget = ref(null) // 駁回原因輸入面板的對象
const rejectReason = ref('')
const STATUS_META = {
unsubmitted: { label: '未送審', cls: 'bg-slate-100 text-slate-500' },
pending: { label: '待審核', cls: 'bg-amber-100 text-amber-700' },
approved: { label: '已通過', cls: 'bg-teal-100 text-teal-700' },
rejected: { label: '已駁回', cls: 'bg-rose-100 text-rose-600' },
}
function statusMeta(p) {
return STATUS_META[p.provider_profile?.verification_status] ?? STATUS_META.unsubmitted
}
async function fetchProviders() { async function fetchProviders() {
loading.value = true loading.value = true
@@ -12,12 +27,19 @@ async function fetchProviders() {
const params = {} const params = {}
if (search.value) params.q = search.value if (search.value) params.q = search.value
const res = await adminApi.get('/admin/providers', { params }) const res = await adminApi.get('/admin/providers', { params })
providers.value = res.data.data providers.value = pendingOnly.value
? res.data.data.filter(p => p.provider_profile?.verification_status === 'pending')
: res.data.data
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function togglePendingFilter() {
pendingOnly.value = !pendingOnly.value
fetchProviders()
}
async function toggleActive(p) { async function toggleActive(p) {
try { try {
const res = await adminApi.put(`/admin/providers/${p.id}/toggle-active`) const res = await adminApi.put(`/admin/providers/${p.id}/toggle-active`)
@@ -25,10 +47,32 @@ async function toggleActive(p) {
} catch (e) { alert(e.response?.data?.message || '操作失敗') } } catch (e) { alert(e.response?.data?.message || '操作失敗') }
} }
async function toggleVerified(p) { async function viewCertifications(p) {
try { try {
const res = await adminApi.put(`/admin/providers/${p.id}/toggle-verified`) const res = await adminApi.get('/admin/verifications', { params: { status: 'all' } })
if (p.provider_profile) p.provider_profile.is_verified = res.data.data.is_verified const row = res.data.data.find(v => v.user_id === p.id)
certsOf.value = { name: p.name, certifications: row?.certifications ?? [] }
} catch (e) { alert(e.response?.data?.message || '讀取失敗') }
}
async function approve(p) {
try {
await adminApi.put(`/admin/verifications/${p.id}/approve`)
await fetchProviders()
} catch (e) { alert(e.response?.data?.message || '操作失敗') }
}
function openReject(p) {
rejectTarget.value = p
rejectReason.value = ''
}
async function confirmReject() {
if (!rejectReason.value.trim()) { alert('請填寫駁回原因'); return }
try {
await adminApi.put(`/admin/verifications/${rejectTarget.value.id}/reject`, { reason: rejectReason.value.trim() })
rejectTarget.value = null
await fetchProviders()
} catch (e) { alert(e.response?.data?.message || '操作失敗') } } catch (e) { alert(e.response?.data?.message || '操作失敗') }
} }
@@ -44,6 +88,9 @@ onMounted(fetchProviders)
class="flex-1 border border-slate-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400" /> class="flex-1 border border-slate-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400" />
<button @click="fetchProviders" <button @click="fetchProviders"
class="bg-slate-800 text-white px-5 py-2 rounded-lg text-sm hover:bg-slate-700 transition">搜尋</button> class="bg-slate-800 text-white px-5 py-2 rounded-lg text-sm hover:bg-slate-700 transition">搜尋</button>
<button @click="togglePendingFilter"
:class="pendingOnly ? 'bg-amber-500 text-white' : 'bg-amber-50 text-amber-700'"
class="px-5 py-2 rounded-lg text-sm hover:opacity-80 transition">待審核</button>
</div> </div>
<div v-if="loading" class="text-center text-slate-400 py-20">載入中...</div> <div v-if="loading" class="text-center text-slate-400 py-20">載入中...</div>
@@ -67,9 +114,8 @@ onMounted(fetchProviders)
</td> </td>
<td class="px-6 py-4 text-slate-500">{{ p.provider_profile?.business_name || '-' }}</td> <td class="px-6 py-4 text-slate-500">{{ p.provider_profile?.business_name || '-' }}</td>
<td class="px-6 py-4 text-center"> <td class="px-6 py-4 text-center">
<span :class="p.provider_profile?.is_verified ? 'bg-teal-100 text-teal-700' : 'bg-slate-100 text-slate-500'" <span :class="statusMeta(p).cls" class="text-xs px-2 py-1 rounded-full font-medium">
class="text-xs px-2 py-1 rounded-full font-medium"> {{ statusMeta(p).label }}
{{ p.provider_profile?.is_verified ? '已驗證' : '未驗證' }}
</span> </span>
</td> </td>
<td class="px-6 py-4 text-center"> <td class="px-6 py-4 text-center">
@@ -79,11 +125,18 @@ onMounted(fetchProviders)
</span> </span>
</td> </td>
<td class="px-6 py-4 text-center"> <td class="px-6 py-4 text-center">
<div class="flex justify-center gap-2"> <div class="flex justify-center gap-2 flex-wrap">
<button @click="toggleVerified(p)" <button @click="viewCertifications(p)"
:class="p.provider_profile?.is_verified ? 'bg-slate-100 text-slate-600' : 'bg-teal-50 text-teal-700'" class="text-xs px-3 py-1 rounded-lg bg-slate-50 text-slate-600 hover:opacity-80 transition">
class="text-xs px-3 py-1 rounded-lg hover:opacity-80 transition"> 證照
{{ p.provider_profile?.is_verified ? '取消驗證' : '驗證' }} </button>
<button v-if="p.provider_profile?.verification_status === 'pending'" @click="approve(p)"
class="text-xs px-3 py-1 rounded-lg bg-teal-50 text-teal-700 hover:opacity-80 transition">
通過
</button>
<button v-if="['pending', 'approved'].includes(p.provider_profile?.verification_status)" @click="openReject(p)"
class="text-xs px-3 py-1 rounded-lg bg-rose-50 text-rose-600 hover:opacity-80 transition">
駁回
</button> </button>
<button @click="toggleActive(p)" <button @click="toggleActive(p)"
:class="p.is_active ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-700'" :class="p.is_active ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-700'"
@@ -99,5 +152,34 @@ onMounted(fetchProviders)
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- 證照檢視面板 -->
<div v-if="certsOf" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50" @click.self="certsOf = null">
<div class="bg-white rounded-2xl p-6 max-w-lg w-full mx-4">
<h2 class="font-semibold text-slate-800 mb-4">{{ certsOf.name }} 的證照</h2>
<p v-if="certsOf.certifications.length === 0" class="text-sm text-slate-400 py-6 text-center">尚未上傳證照</p>
<div v-else class="grid grid-cols-2 gap-3">
<a v-for="c in certsOf.certifications" :key="c.id" :href="c.url" target="_blank"
class="rounded-xl overflow-hidden border hover:opacity-90 transition">
<img :src="c.url" loading="lazy" class="w-full h-36 object-cover" />
</a>
</div>
<button @click="certsOf = null" class="mt-4 w-full bg-slate-100 text-slate-600 py-2 rounded-xl text-sm hover:bg-slate-200 transition">關閉</button>
</div>
</div>
<!-- 駁回原因面板 -->
<div v-if="rejectTarget" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50" @click.self="rejectTarget = null">
<div class="bg-white rounded-2xl p-6 max-w-md w-full mx-4">
<h2 class="font-semibold text-slate-800 mb-1">駁回 {{ rejectTarget.name }}</h2>
<p class="text-xs text-slate-400 mb-4">原因將以站內通知與 Email 告知教練</p>
<textarea v-model="rejectReason" rows="3" maxlength="500" placeholder="例如:證照影像不清晰,請重新拍攝上傳"
class="w-full border border-slate-300 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-300"></textarea>
<div class="flex gap-2 mt-4">
<button @click="rejectTarget = null" class="flex-1 bg-slate-100 text-slate-600 py-2 rounded-xl text-sm hover:bg-slate-200 transition">取消</button>
<button @click="confirmReject" class="flex-1 bg-rose-600 text-white py-2 rounded-xl text-sm hover:bg-rose-700 transition">確認駁回</button>
</div>
</div>
</div>
</main> </main>
</template> </template>
@@ -0,0 +1,138 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import coachApi from '../../api/coachAxios'
const status = ref('unsubmitted')
const reason = ref(null)
const certs = ref([])
const loading = ref(true)
const uploading = ref(false)
const submitting = ref(false)
const errorMessage = ref('')
const STATUS_META = {
unsubmitted: { label: '尚未送審', cls: 'bg-slate-100 text-slate-600', hint: '上傳教練證照後即可送出審核。通過審核前,你的課程不會公開曝光。' },
pending: { label: '審核中', cls: 'bg-amber-100 text-amber-700', hint: '已送出審核,平台將盡快處理。審核期間證照不可變更。' },
approved: { label: '已通過', cls: 'bg-teal-100 text-teal-700', hint: '你的教練資格已通過審核,課程已公開曝光並可接受預約。' },
rejected: { label: '未通過', cls: 'bg-rose-100 text-rose-700', hint: '請依駁回原因補正證照後重新送審。' },
}
const meta = computed(() => STATUS_META[status.value] ?? STATUS_META.unsubmitted)
const canEdit = computed(() => ['unsubmitted', 'rejected'].includes(status.value))
const canSubmit = computed(() => canEdit.value && certs.value.length > 0)
async function load() {
loading.value = true
try {
const res = await coachApi.get('/provider/verification')
status.value = res.data.data.verification_status
reason.value = res.data.data.rejection_reason
certs.value = res.data.data.certifications
} finally {
loading.value = false
}
}
async function uploadCert(event) {
const file = event.target.files[0]
if (!file) return
errorMessage.value = ''
uploading.value = true
try {
const form = new FormData()
form.append('image', file)
await coachApi.post('/provider/verification/certifications', form)
await load()
} catch (e) {
errorMessage.value = e.response?.data?.message || '上傳失敗'
} finally {
uploading.value = false
event.target.value = ''
}
}
async function removeCert(id) {
errorMessage.value = ''
try {
await coachApi.delete(`/provider/verification/certifications/${id}`)
await load()
} catch (e) {
errorMessage.value = e.response?.data?.message || '刪除失敗'
}
}
async function submit() {
errorMessage.value = ''
submitting.value = true
try {
await coachApi.post('/provider/verification/submit')
await load()
} catch (e) {
errorMessage.value = e.response?.data?.message || '送審失敗'
} finally {
submitting.value = false
}
}
onMounted(load)
</script>
<template>
<div class="p-6 max-w-3xl mx-auto">
<h1 class="text-2xl font-bold text-gray-800 mb-2">驗證申請</h1>
<p class="text-gray-500 text-sm mb-6">上傳教練證照並送審通過後課程才會公開曝光</p>
<div v-if="loading" class="text-gray-400">載入中</div>
<template v-else>
<!-- 狀態卡 -->
<div class="bg-white rounded-2xl shadow p-6 mb-6">
<div class="flex items-center gap-3 mb-2">
<span class="text-sm font-medium text-gray-600">目前狀態</span>
<span :class="meta.cls" class="px-3 py-1 rounded-full text-sm font-medium">{{ meta.label }}</span>
</div>
<p class="text-sm text-gray-500">{{ meta.hint }}</p>
<div v-if="status === 'rejected' && reason" class="mt-3 bg-rose-50 border border-rose-200 rounded-xl p-3 text-sm text-rose-700">
駁回原因{{ reason }}
</div>
</div>
<!-- 證照管理 -->
<div class="bg-white rounded-2xl shadow p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="font-semibold text-gray-700">教練證照{{ certs.length }}/3</h2>
<label v-if="canEdit && certs.length < 3"
class="text-sm bg-ocean-600 hover:bg-ocean-700 text-white px-4 py-2 rounded-xl cursor-pointer transition"
:class="{ 'opacity-50 pointer-events-none': uploading }">
{{ uploading ? '上傳中…' : ' 上傳證照' }}
<input type="file" accept="image/jpeg,image/png,image/webp" class="hidden" @change="uploadCert" />
</label>
</div>
<div v-if="certs.length === 0" class="text-sm text-gray-400 py-6 text-center">尚未上傳任何證照</div>
<div v-else class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div v-for="cert in certs" :key="cert.id" class="relative group rounded-xl overflow-hidden border">
<a :href="cert.url" target="_blank">
<img :src="cert.url" loading="lazy" class="w-full h-32 object-cover" />
</a>
<button v-if="canEdit"
@click="removeCert(cert.id)"
class="absolute top-1 right-1 bg-black/60 text-white text-xs px-2 py-1 rounded-lg opacity-0 group-hover:opacity-100 transition">
刪除
</button>
</div>
</div>
</div>
<p v-if="errorMessage" class="text-sm text-rose-600 mb-4">{{ errorMessage }}</p>
<button v-if="canEdit"
@click="submit"
:disabled="!canSubmit || submitting"
class="w-full bg-ocean-600 hover:bg-ocean-700 disabled:opacity-40 disabled:cursor-not-allowed text-white font-medium py-3 rounded-xl transition">
{{ submitting ? '送出中…' : (status === 'rejected' ? '重新送出審核' : '送出審核') }}
</button>
</template>
</div>
</template>
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-01
@@ -0,0 +1,97 @@
## Context
CFDivePlatform 使用 Sanctum Bearer Token 認證,token 儲存於前端 localStorage。安全評估發現三個在當前架構下可直接修復的高危漏洞,不需要切換認證機制。此 change 針對性修補,範圍刻意控制在最小改動。
## Goals / Non-Goals
**Goals:**
- Token 設定有限期,降低洩漏後的持續暴露時間
- 消除 Google OAuth redirect URL 中的 token 洩漏路徑
- 對登入端點加入基本的暴力破解防護
**Non-Goals:**
- 不切換至 HttpOnly Cookie(需要 HTTPS、CSRF token、OAuth 重構,成本過高)
- 不修改既有 token 的儲存位置(localStorage → sessionStorage 留 P1
- 不實作 token refresh / silent renew 機制(留 P1
- 不清除資料庫中現存的永不過期 token(`expires_at = null` 的舊記錄接受自然淘汰;`prune-expired` 只刪 `expires_at` 已逾期的記錄,兩者不衝突)
## Decisions
### 1. Token 過期時間設為 7 天
**選擇**`sanctum.expiration = 10080`(分鐘)
**原因**:7 天符合「使用者每週至少登入一次」的合理假設,過短(如 1 天)會顯著提高重新登入頻率影響 UX,過長則縮減不了多少攻擊視窗。
**替代方案考慮**
- 1 天:洩漏視窗最小,但 UX 衝擊大
- 30 天:常見電商慣例,但本平台含金流元素不適用
- 無限:現狀,排除
**副作用**`sanctum.expiration` 只影響新建立的 token,既有 `personal_access_tokens``expires_at = null` 的記錄不受影響。可接受——舊 token 在使用者下次登入後自然被新 token 取代。
**清理機制**:啟用 expiration 後過期 token 不會自動從資料庫刪除,需搭配 `sanctum:prune-expired --hours=168` 排程(`routes/console.php`)每日清理,避免 `personal_access_tokens` 表持續膨脹。
---
### 2. Google OAuth redirect 改用 URL Fragment
**選擇**`redirect(config('app.frontend_url') . '/auth/callback#token=' . $token)`
**原因**
- URL fragment`#` 後的部分)依 HTTP 規範不會被瀏覽器送到 server,因此**不會出現在 Nginx/server log**
- Fragment 不會包含在 `Referer` header 中
- Fragment 不會出現在瀏覽器歷史的完整記錄中(部分瀏覽器歷史仍記錄 fragment,但不在伺服器端)
- 前端改動僅一行:`route.query.token``new URLSearchParams(window.location.hash.substring(1)).get('token')`
**替代方案考慮**
- **後端 POST 回 token**OAuth provider 只能 redirect,無法直接 POST
- **server-side session 中繼**:後端 redirect 到 `/auth/callback?code=<random>` 再由前端換 token——更安全但需要 server-side session store,架構複雜度提高
- **保持 query string**:現狀,洩漏路徑未消除,排除
**選擇 fragment 而非 server-side session 的原因**fragment 方案完全消除 server log 洩漏問題,且改動最小,對此平台規模足夠。
---
### 3. Rate Limiting 使用 Laravel 內建 throttle middleware
**選擇**
- Member / Provider 登入:throttle:5,1
- Admin 登入:throttle:3,1(帳號權限較高,適當收緊)
**原因**
- Laravel 11 throttle middleware 基於 IP,不需要額外套件
- 觸發時自動回傳 HTTP 429 並帶 `Retry-After` header
- 5次/分鐘對正常使用者幾乎無感,但讓暴力破解的時間成本從秒級提高到分鐘級
**替代方案考慮**
- `throttle:10,1`:太寬鬆,對所有角色排除
- `throttle:3,1` for member/provider:可能誤傷快速填錯密碼的正常使用者,故 member/provider 維持 5 次;admin 因帳號影響範圍大,3 次的誤傷成本可接受
- Progressive delay(每次失敗增加等待時間):需要額外實作,P0 不引入複雜度
**僅套用登入端點**:register 端點不加(新帳號建立的攻擊面不同,留 P1)
## Risks / Trade-offs
- **既有 token 不過期**`expiration` 改動不回溯,需接受現有洩漏的舊 token 持續有效直到使用者重新登入。風險可接受,因為:(1) 此為首次設定,無歷史洩漏事件;(2) 使用者正常使用就會刷新。
- **`personal_access_tokens` 表膨脹**:啟用 expiration 後過期記錄不自動清除,需排程 `sanctum:prune-expired` → 已列入 tasks。
- **`env()` 在 config cache 下失效**`SocialAuthController` 原本使用 `env('FRONTEND_URL')`,執行 `php artisan config:cache` 後(production 必做)`env()` 回傳 null,導致 redirect 到錯誤 URL。此 change 一併修正為 `config('app.frontend_url')``config/app.php` 已有此鍵)。
- **fragment 在部分瀏覽器歷史仍可見**Chrome DevTools Application → Session History 仍記錄完整 URL 含 fragment,但這是本地攻擊面(攻擊者需存取使用者設備),遠低於 server log 洩漏的遠端攻擊面。
- **IP-based throttle 可被繞過**:攻擊者可換 IP 或使用代理。此方案擋的是基本掃描攻擊,不是針對性攻擊。P1 可加 account-level lockout。
## Migration Plan
1. 套用 `config/sanctum.php` 修改,重啟 PHP-FPM(或 Docker container
2. 在 `routes/console.php` 新增 `Schedule::command('sanctum:prune-expired --hours=168')->daily();`(無需重啟,下次排程週期生效)
3. 套用 `routes/api.php` 修改,無需重啟(路由快取需清除:`php artisan route:clear`
4. 套用後端 `SocialAuthController.php` 修改
5. 套用前端 `AuthCallbackView.vue` 修改,重新 build frontend`npm run build`
6. 驗證:手動測試 Google OAuth 登入流程、登入頻率限制觸發、手動執行 `php artisan sanctum:prune-expired --hours=168` 確認排程指令可正常執行
**Rollback**:各項變更相互獨立,可逐項還原。Token 過期設定還原後,新建 token 重新改為不過期(舊的已過期的 token 無法恢復)。
## Open Questions
- **Coach 登入端點 `/api/provider/login` 是否也需要 rate limiting**(已包含在此 change 中,與 member 同風險級別,統一採用 `throttle:5,1`
- **Admin 登入是否需要更嚴格的限制?****決策:改為 `throttle:3,1`,在此 change 實作。** 管理員帳號一旦被暴力破解,影響範圍遠大於一般使用者(可存取所有用戶資料、審核功能),更嚴格的限制可接受,正常管理員操作不太可能在 1 分鐘內連續嘗試 3 次以上。
- **是否需要對 Google OAuth callback 加入 state parameter 驗證(CSRF for OAuth)?****確認留 P1。** 風險評估:`stateless()` 模式下缺少 state 驗證,理論上存在 CSRF-for-OAuth 攻擊面(攻擊者偽造 callback 讓受害者綁定攻擊者的 Google 帳號)。緩解因素:(1) Google OAuth 本身要求使用者主動授權,不能靜默觸發;(2) 攻擊者需控制受害者瀏覽器的 callback 請求,難度高;(3) 平台目前無高價值資產(金流未上線)。P1 實作時加入 `state` parameter 並在 session 中驗證。
@@ -0,0 +1,29 @@
## Why
目前認證機制存在三個高危漏洞:Token 永不過期(一旦洩漏攻擊者永久持有存取權)、Google OAuth callback 將 Sanctum token 附在 URL query string(出現在 server log、瀏覽器歷史、Referer header),以及登入端點無頻率限制(暴力破解無防護)。三項缺陷均可在不入侵伺服器的前提下被利用。
## What Changes
- `config/sanctum.php`:將 `expiration``null` 改為 `10080`(7 天 × 24 小時 × 60 分鐘)
- `SocialAuthController::handleGoogleCallback()`redirect URL 改用 `#token=` fragment 取代 `?token=` query string
- `AuthCallbackView.vue`:改從 `window.location.hash` 讀取 token,不再使用 `route.query.token`
- `routes/api.php`:對 member / provider / admin 三個登入端點套用 `throttle:5,1` middleware
## Capabilities
### New Capabilities
- `login-rate-limiting`: 登入端點的請求頻率限制——每個 IP 每分鐘最多 5 次,超過回傳 429
### Modified Capabilities
- `provider-auth`: 新增登入 rate limiting 場景;新增 token 有效期行為(7 天後失效)
- `admin-auth`: 新增登入 rate limiting 場景;新增 token 有效期行為(7 天後失效)
- `member-portal-ui`: Google OAuth callback 行為變更——token 改由 URL fragment 傳遞,前端改從 hash 讀取
## Impact
- **後端**`config/sanctum.php``app/Http/Controllers/API/SocialAuthController.php``routes/api.php`
- **前端**`frontend/src/views/AuthCallbackView.vue`
- **現有 token**`expiration` 設定僅影響**新建立**的 token,既有的 `personal_access_tokens` 不受影響(`expires_at` 欄位為 null 的舊 token 仍長期有效),需另行清除或接受自然淘汰
- **無破壞性變更**API 介面、response 格式、前端路由均不改變
@@ -0,0 +1,29 @@
## MODIFIED Requirements
### Requirement: 管理員登入
後端 SHALL 提供 `POST /api/admin/login`(現有 AuthController 方法),驗證 email/password 並確認 role=admin,回傳有效期 7 天的 Bearer token。
#### Scenario: 正確帳密登入
- **WHEN** 管理員送出正確 email 與 password
- **THEN** 回傳 HTTP 200`{ status: true, data: { user, token, token_type: "Bearer" } }`
#### Scenario: 非 admin 角色帳號嘗試登入
- **WHEN** role 非 admin 的帳號嘗試呼叫此端點
- **THEN** 回傳 HTTP 401`{ status: false, message: "電子郵件或密碼錯誤" }`
#### Scenario: 超過登入頻率限制
- **WHEN** 同一 IP 在 1 分鐘內送出超過 3 次登入請求
- **THEN** 回傳 HTTP 429,帶有 `Retry-After` header
## ADDED Requirements
### Requirement: 管理員 Bearer Token 有效期
後端 SHALL 發行有效期為 7 天的管理員 Bearer Token,過期後需重新登入。
#### Scenario: Token 過期後管理員請求被拒絕
- **WHEN** 管理員使用已過期(超過 7 天)的 token 送出 API 請求
- **THEN** 回傳 HTTP 401token 視為無效
#### Scenario: 有效期內 token 正常通過認證
- **WHEN** 管理員使用未過期的 token 送出 API 請求
- **THEN** 請求正常通過認證,回傳對應資源
@@ -0,0 +1,20 @@
## ADDED Requirements
### Requirement: 登入頻率限制
後端 SHALL 對所有登入端點套用 IP-based 頻率限制,超過限制時回傳 HTTP 429。Member 與 Provider 每 IP 每分鐘最多 5 次;Admin 因影響範圍更廣,限制為每 IP 每分鐘最多 3 次。
#### Scenario: Member / Provider 正常登入不受影響
- **WHEN** 同一 IP 在 1 分鐘內對 `/api/member/login``/api/provider/login` 送出 5 次以內的請求
- **THEN** 請求正常處理,回傳對應的登入結果(200 成功或 401 失敗)
#### Scenario: Member / Provider 超過頻率限制
- **WHEN** 同一 IP 在 1 分鐘內送出第 6 次 member 或 provider 登入請求
- **THEN** 回傳 HTTP 429,並帶有 `Retry-After` header 指示等待時間
#### Scenario: Admin 超過頻率限制
- **WHEN** 同一 IP 在 1 分鐘內送出第 4 次 `/api/admin/login` 請求
- **THEN** 回傳 HTTP 429,並帶有 `Retry-After` header 指示等待時間
#### Scenario: 頻率限制重置
- **WHEN** 頻率限制觸發後等待 1 分鐘
- **THEN** 該 IP 的請求計數重置,可再次正常送出登入請求
@@ -0,0 +1,37 @@
## MODIFIED Requirements
### Requirement: 登入頁
前端 SHALL 提供 `/login` 頁面,供會員以 email/password 登入,以及 Google OAuth 登入入口。
#### Scenario: Email/Password 登入成功
- **WHEN** 使用者填入正確的 email 與 password 並送出
- **THEN** 呼叫 `POST /api/member/login`,儲存回傳的 token 至 localStorage,導航至 `/courses`
#### Scenario: 登入失敗
- **WHEN** 使用者填入錯誤的 email 或 password
- **THEN** 頁面顯示錯誤訊息,不跳轉
#### Scenario: Google OAuth 登入
- **WHEN** 使用者點擊「以 Google 登入」按鈕
- **THEN** 瀏覽器導航至後端 `GET /api/auth/google/redirect`,開始 OAuth 流程
#### Scenario: 超過登入頻率限制
- **WHEN** 同一 IP 在 1 分鐘內送出超過 5 次登入請求
- **THEN** 前端顯示適當的錯誤訊息(對應後端回傳的 HTTP 429)
## ADDED Requirements
### Requirement: Google OAuth Callback 處理
前端 SHALL 在 `/auth/callback` 路由讀取 URL fragment`#token=<value>`)取得 Sanctum token,完成 OAuth 登入後將 token 存入 localStorage 並導航至 `/courses`。token 不得透過 URL query string 傳遞。
#### Scenario: OAuth callback 成功取得 token
- **WHEN** 後端 OAuth callback redirect 到 `/auth/callback#token=<token>`
- **THEN** 前端從 `window.location.hash` 解析 token,呼叫 `/api/member/profile` 取得使用者資料,呼叫 `auth.setAuth()` 儲存認證狀態,並導航至 `/courses`
#### Scenario: OAuth callback 缺少 token
- **WHEN** redirect 到 `/auth/callback` 但 hash 中無 `token` 參數
- **THEN** 前端導航至 `/login?error=oauth_failed`,顯示錯誤訊息
#### Scenario: URL 中不留存 token
- **WHEN** callback 頁面成功處理 token 後
- **THEN** 瀏覽器網址列不顯示 token(使用 `history.replaceState` 清除 hash
@@ -0,0 +1,33 @@
## MODIFIED Requirements
### Requirement: 教練帳號登入
後端 SHALL 提供 `POST /api/provider/login`,驗證 email/password 並回傳 Sanctum Bearer token,僅限 role=provider 帳號。回傳的 token 有效期為 7 天。
#### Scenario: 正確帳密登入成功
- **WHEN** 教練送出正確的 email 與 password
- **THEN** 回傳 HTTP 200,包含 `{ status: true, data: { user, token, token_type: "Bearer" } }`
#### Scenario: 錯誤帳密登入失敗
- **WHEN** 教練送出錯誤的 email 或 password
- **THEN** 回傳 HTTP 401`{ status: false, message: "帳號或密碼錯誤" }`
#### Scenario: 會員帳號無法用教練登入
- **WHEN** role=member 的帳號嘗試呼叫 `/api/provider/login`
- **THEN** 回傳 HTTP 403`{ status: false, message: "此帳號非教練角色" }`
#### Scenario: 超過登入頻率限制
- **WHEN** 同一 IP 在 1 分鐘內送出超過 5 次登入請求
- **THEN** 回傳 HTTP 429,帶有 `Retry-After` header
## ADDED Requirements
### Requirement: Bearer Token 有效期
後端 SHALL 發行有效期為 7 天的 Sanctum Bearer Token,過期後使用者必須重新登入。
#### Scenario: Token 過期後請求被拒絕
- **WHEN** 教練使用已過期(超過 7 天)的 token 送出 API 請求
- **THEN** 回傳 HTTP 401token 視為無效
#### Scenario: 有效期內 token 正常運作
- **WHEN** 教練使用未過期的 token 送出 API 請求
- **THEN** 請求正常通過認證
@@ -0,0 +1,30 @@
## 1. Token 過期時間設定
- [x] 1.1 [後端] 修改 `config/sanctum.php`:將 `'expiration' => null` 改為 `'expiration' => 60 * 24 * 7`10080 分鐘)
- [x] 1.2 [後端] 在 `routes/console.php` 新增排程:`Schedule::command('sanctum:prune-expired --hours=168')->daily();`,清除累積的過期 token 記錄
- [x] 1.3 [後端] 執行 `php artisan config:clear` 確認設定生效
## 2. Google OAuth Token 洩漏修復
- [x] 2.1 [後端] 修改 `app/Http/Controllers/API/SocialAuthController.php` 第 109、111 行:`env('FRONTEND_URL')` 改為 `config('app.frontend_url')`(防止 `config:cache` 後 env() 回傳 null),同時將第 109 行 `?token=` 改為 `#token=`URL fragment
- [x] 2.2 [前端] 修改 `frontend/src/views/AuthCallbackView.vue`:將讀取 `route.query.token` 改為讀取 `window.location.hash``new URLSearchParams(window.location.hash.substring(1)).get('token')`
## 3. 登入端點 Rate Limiting
- [x] 3.1 [後端] 修改 `routes/api.php`:對 `POST /api/member/login` 套用 `throttle:5,1` middleware
- [x] 3.2 [後端] 修改 `routes/api.php`:對 `POST /api/provider/login` 套用 `throttle:5,1` middleware
- [x] 3.3 [後端] 修改 `routes/api.php`:對 `POST /api/admin/login` 套用 `throttle:3,1` middleware(管理員帳號影響範圍更廣,採用更嚴格限制)
- [x] 3.4 [後端] 執行 `php artisan route:clear` 清除路由快取
## 4. 自動化測試
- [x] 4.1 [測試] 在 `tests/Feature/AuthRateLimitTest.php` 建立 Feature test:驗證 `POST /api/member/login` 第 6 次請求回傳 HTTP 429,並斷言 response header 含 `Retry-After`
- [x] 4.2 [測試] 在同一 test file 補充:`POST /api/provider/login` 第 6 次回傳 429`POST /api/admin/login` 第 4 次回傳 429admin 限制為 throttle:3,1
- [x] 4.3 [測試] 在 `tests/Feature/AuthRateLimitTest.php` 補充:正常登入(5 次以內)不受 throttle 影響,回傳 200 或 401
## 5. 手動驗證
- [x] 5.1 [整合測試] 驗證 Google OAuth 登入流程:登入後確認 Nginx access log 中 `/auth/google/callback` 的請求 URL 不含 token
- [x] 5.2 [整合測試] 驗證 Token 過期:在 `personal_access_tokens` 表手動將 `expires_at` 設為過去時間,確認 API 回傳 401 並自動登出
- [x] 5.3 [整合測試] 執行 `php artisan sanctum:prune-expired --hours=168`,確認 `personal_access_tokens` 中過期記錄被清除
- [x] 5.4 [整合測試] 驗證正常登入流程不受影響:member / provider 帳號各測試一次完整登入 → 操作 → 登出流程
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-01
@@ -0,0 +1,115 @@
## Context
P0 完成後,Bearer Token 有 7 天過期保護,但缺少自動續期機制,且 token 仍存於 localStorageXSS 可讀)。此 change 引入 sliding window refresh 搭配 sessionStorage,兩者互相依賴:sessionStorage 在分頁/瀏覽器關閉後清除 tokenrefresh 機制確保 token 在主動使用期間不過期,兩者缺一則安全性或 UX 有所折損。
## Goals / Non-Goals
**Goals:**
- Token 在主動使用期間無限 sliding,不需手動重新登入
- Token 改存 sessionStorage,關閉分頁/瀏覽器後自動清除
- 多個並發 401 只觸發一次 refresh,其他請求排隊等待結果
- member、provider、admin 三個角色統一行為
**Non-Goals:**
- 不引入獨立的 refresh token(避免 token 儲存複雜度倍增)
- 不實作 token rotation 以外的安全機制(如 device binding
- 不處理 admin panel(尚未實作)的 refresh 前端邏輯
- 不改變 7 天的 token 有效期設定
## Decisions
### 1. 使用現有 Bearer Token 做 refresh(無獨立 refresh token
**選擇**`POST /api/{role}/refresh` 接受現有 Bearer token → revoke → 發新 token
**原因**
- 不需新增 DB 欄位或 token 類型
- 實作最簡單:後端 3 個端點各約 5 行
- 安全性足夠:refresh 需要有效 token,無法憑空 refresh
- 缺點:token 只要還有效就能 refresh(不區分「剛登入」vs「長期 refresh」),可接受
**替代方案**:獨立 refresh token(長效 + 短效 access token 組合)——更安全但需儲存兩個 token、多一層 DB 查詢,複雜度不值得在此階段引入。
---
### 2. Refresh 在 axios response interceptor 觸發(401 時)
**選擇**interceptor 攔截 401 → 呼叫 refresh → 成功則以新 token retry 原請求 → 失敗才登出
**原因**
- 對所有 API 呼叫自動生效,不需每個頁面個別處理
- 延遲 refresh 到實際需要時(lazy),不需 proactive timer
**並發問題**:多個 API 同時 401 時,只允許一個 refresh 進行中。實作方式:module-level `refreshing` flag + `pendingRequests` queue。refresh 進行中的其他 401 請求排入 queuerefresh 完成後統一 retry。
**替代方案**Proactive refreshtoken 快過期前主動 refresh)——需要 timer 或每次請求檢查過期時間,複雜度較高,且 sessionStorage 無法跨分頁共享計時狀態。
#### Refresh 觸發條件
下列情況回傳 401 時**觸發** refresh
- 任何需要認證的 API 請求(`/api/member/*``/api/provider/*``/api/admin/*`
下列情況回傳 401 時**不觸發** refresh(直接傳遞錯誤給呼叫方):
- `/login``/register` 端點(帳密錯誤,非 token 問題)
- `/refresh` 端點本身(防止無限遞迴)
#### Refresh 端點接受的 token 狀態
- **接受**`expires_at` 尚未過期、`personal_access_tokens` 中記錄存在且未被 revoke
- **拒絕**`expires_at` 已過期、token 已被 revoke(前次 refresh 或登出時)、token 格式不合法
#### Refresh 失敗後的前端清理流程
1. 清除 `sessionStorage` 中的 `token`(與 `user`
2. 將 `pendingRequests` queue 中所有等待的 request 以 error reject
3. 重設 `isRefreshing = false`
4. 導向 `/login`(或 `/coach/login`
#### Interceptor 實作約束(Invariants
| 約束 | 說明 |
|---|---|
| 同時間只允許一個 refresh | `isRefreshing` flag 確保並發 401 不重複發 refresh |
| Refresh request 不進入 refresh 流程 | 偵測 `config.url.includes('/refresh')` 跳過 |
| 每個原始 request 最多 retry 一次 | `config._retry` flag,已 retry 的請求不再觸發 refresh |
| Refresh 失敗時清除 token + 全部登出 | 清 sessionStorage → reject all pending → redirect |
---
### 3. sessionStorage 取代 localStorage
**選擇**`sessionStorage.setItem('token', ...)` 取代 `localStorage.setItem('token', ...)`
**原因**
- 分頁關閉後自動清除,攻擊視窗縮短
- 重整頁面(F5sessionStorage 仍保留,token 不會消失,不需 refresh
- Refresh 機制是為了處理 token **過期**(7 天未使用)回傳 401 的情況,與頁面重整無關
**副作用**:多個分頁不共享登入狀態(在一個分頁登入,另一個分頁需重新登入)。可接受——per-tab session 對安全性更好。
---
### 4. AuthCallbackView 改用 sessionStorage
Google OAuth callback 目前直接呼叫 `localStorage.setItem('token', token)`(在 `auth.setAuth` 之前)。需同步改為 sessionStorage,否則 callback 存的位置與 axios interceptor 讀取的位置不一致。
## Risks / Trade-offs
- **多分頁體驗退化**:使用者在 Tab A 登入,Tab B 不會自動登入。對大部分使用情境可接受,但若使用者習慣多分頁操作會需要適應。
- **Refresh 端點被濫用**:攻擊者持有有效 token 時可持續呼叫 refresh。refresh 端點目前**沒有 rate limiting**,這是本次 change 的明確決策:先以較低複雜度完成 sliding refresh 流程;若後續觀察到濫用或異常流量,再於 P2 補上 rate limiting。
- **Refresh 失敗的 edge case**:網路短暫中斷時 refresh 失敗 → 強制登出。比起讓使用者停在壞狀態,主動登出更安全,可接受。
- **Queue 實作複雜度**`pendingRequests` queue 需正確處理 promise resolve/reject,實作錯誤會導致請求永久 pending。需要仔細測試。
## Migration Plan
1. 後端:新增 `refreshMember``refreshProvider``refreshAdmin` 方法至 `AuthController.php`,新增對應路由至 `routes/api.php`
2. 前端:改寫 `axios.js``coachAxios.js` 的 interceptorrefresh-then-retry + queue
3. 前端:更新 `auth.js``coachAuth.js``AuthCallbackView.vue` 的 storage 讀寫改為 sessionStorage
4. 驗證:手動測試 token 過期後自動 refresh、多並發 401、分頁關閉後 token 清除
**Rollback**:各項獨立可回滾。storage 改回 localStorage 不影響功能,只影響安全性。
## Open Questions
- **Refresh 端點是否需要 rate limiting****決策:此 change 不加。** 持有有效 token 才能 refresh,攻擊面有限;P0 已對登入端點加 throttle。若未來觀察到濫用再加 `throttle:10,1`
- **Admin refresh 前端邏輯是否需要?****決策:此 change 只建後端端點(`POST /api/admin/refresh`),不實作前端 interceptor。** Admin panel 尚未實作,前端邏輯待 admin panel 開發時一併處理。
@@ -0,0 +1,30 @@
## Why
P0 已設定 Bearer Token 7 天過期,但目前缺乏自動續期機制——token 過期後使用者直接被踢回登入頁,且 token 持久存在 localStorageJS 可讀,XSS 攻擊面)。引入 token refresh 搭配 sessionStorage 可同時解決「過期 UX 不佳」和「token 洩漏視窗過長」兩個問題,且兩者必須一起實作才有意義。
## What Changes
- **後端**:新增三個 refresh 端點(`POST /api/member/refresh``POST /api/provider/refresh``POST /api/admin/refresh`),接受現有 Bearer tokenrevoke 舊 token 並發行新 7 天 tokensliding window
- **前端 axios interceptor**:將現有「401 直接登出」改為「401 → 先嘗試 refresh → 成功則 retry 原始請求 → 失敗才登出」;同時處理多個並發 401 只發一次 refresh 請求
- **前端 token 儲存**`auth.js``coachAuth.js` 的 localStorage 改為 sessionStorage`axios.js``coachAxios.js` interceptor 讀取來源同步更新
- **`AuthCallbackView.vue`**Google OAuth callback 存 token 改寫 sessionStorage
## Capabilities
### New Capabilities
- `token-refresh`: 三個角色的 refresh API 端點(revoke 舊 token + 發行新 token);前端 axios refresh-then-retry 攔截邏輯,含並發 refresh 去重
### Modified Capabilities
- `member-portal-ui`: `認證狀態管理` 行為變更——token 從 localStorage 改存 sessionStorage;新增自動 refresh 行為(401 時先 refresh 再 retry
- `coach-portal-ui`: `Coach 路由守衛` 讀取的 auth state 改以 sessionStorage 為來源;同樣支援 refresh-then-retry
- `provider-auth`: `Bearer Token 有效期` 新增 sliding window 行為——主動使用 API 的 session 可透過 refresh 無限延續,不需重新登入
- `admin-auth`: `管理員 Bearer Token 有效期` 同上,sliding window via refresh
## Impact
- **後端**`app/Http/Controllers/API/AuthController.php`(新增 refresh 方法)、`routes/api.php`(新增 refresh 路由)
- **前端**`frontend/src/api/axios.js``frontend/src/api/coachAxios.js`interceptor 重寫)、`frontend/src/stores/auth.js``frontend/src/stores/coachAuth.js`sessionStorage)、`frontend/src/views/AuthCallbackView.vue`
- **行為差異**sessionStorage 為 per-tab,多個分頁不共享登入狀態(與現有 localStorage 共享行為不同)
- **無 DB schema 變更**:使用現有 `personal_access_tokens` 表,refresh 透過 revoke + createToken 實作
@@ -0,0 +1,16 @@
## MODIFIED Requirements
### Requirement: 管理員 Bearer Token 有效期
後端 SHALL 發行有效期為 7 天的管理員 Bearer Token。主動使用 API 的 session 可透過 refresh 端點取得新 tokensliding window);閒置超過 7 天後需重新登入。
#### Scenario: Token 過期後管理員請求被拒絕
- **WHEN** 管理員使用已過期(超過 7 天未 refresh)的 token 送出 API 請求
- **THEN** 回傳 HTTP 401token 視為無效
#### Scenario: 有效期內 token 正常通過認證
- **WHEN** 管理員使用未過期的 token 送出 API 請求
- **THEN** 請求正常通過認證,回傳對應資源
#### Scenario: Refresh 延續有效期
- **WHEN** 管理員在 token 過期前呼叫 `POST /api/admin/refresh`
- **THEN** 取得新的 7 天 token,舊 token 失效,有效期重置
@@ -0,0 +1,16 @@
## MODIFIED Requirements
### Requirement: Coach 路由守衛
前端 SHALL 對所有 `/coach/*` 路由(login 除外)加上 navigation guard,未登入時導向 `/coach/login`。auth state 以 sessionStorage 為來源,API 請求支援 refresh-then-retry。
#### Scenario: 未登入訪問 Dashboard
- **WHEN** 未登入使用者直接訪問 `/coach/dashboard`
- **THEN** 自動導向 `/coach/login`
#### Scenario: 分頁關閉後重開需重新登入
- **WHEN** 教練關閉分頁後重新開啟 `/coach/dashboard`
- **THEN** sessionStorage 已清除,自動導向 `/coach/login`
#### Scenario: 登出
- **WHEN** 教練點擊登出
- **THEN** 呼叫 `POST /api/provider/logout`,清除 sessionStorage coach_token / coach_user,導向 `/coach/login`
@@ -0,0 +1,20 @@
## MODIFIED Requirements
### Requirement: 認證狀態管理
前端 SHALL 使用 Pinia store 管理認證狀態,token 存於 sessionStorage(非 localStorage),並在所有需認證的 API 請求自動附加 Bearer token。收到 401 時先嘗試 refresh,成功後 retryrefresh 失敗才清除 session 並導向登入頁。
#### Scenario: 頁面刷新後保持登入狀態
- **WHEN** 已登入使用者在同一分頁重新整理頁面
- **THEN** 從 sessionStorage 還原 token,使用者仍為登入狀態
#### Scenario: 分頁關閉後 token 自動清除
- **WHEN** 使用者關閉瀏覽器分頁或瀏覽器
- **THEN** sessionStorage 自動清除,重新開啟需重新登入
#### Scenario: Token 過期後自動 refresh
- **WHEN** API 請求因 token 過期回傳 401
- **THEN** 自動呼叫 `POST /api/member/refresh`,成功後 retry 原始請求,使用者無感知
#### Scenario: 登出
- **WHEN** 使用者點擊登出
- **THEN** 呼叫 `POST /api/member/logout`,清除 sessionStorage token,導向 `/login`
@@ -0,0 +1,16 @@
## MODIFIED Requirements
### Requirement: Bearer Token 有效期
後端 SHALL 發行有效期為 7 天的 Sanctum Bearer Token。主動使用 API 的 session 可透過 refresh 端點取得新 tokensliding window),持續使用不需重新登入;閒置超過 7 天後 token 過期,需重新登入。
#### Scenario: Token 過期後請求被拒絕
- **WHEN** 教練使用已過期(超過 7 天未 refresh)的 token 送出 API 請求
- **THEN** 回傳 HTTP 401token 視為無效
#### Scenario: 有效期內 token 正常運作
- **WHEN** 教練使用未過期的 token 送出 API 請求
- **THEN** 請求正常通過認證
#### Scenario: Refresh 延續有效期
- **WHEN** 教練在 token 過期前呼叫 `POST /api/provider/refresh`
- **THEN** 取得新的 7 天 token,舊 token 失效,有效期重置
@@ -0,0 +1,75 @@
## ADDED Requirements
### Requirement: Member Token Refresh
後端 SHALL 提供 `POST /api/member/refresh`(需有效 Bearer token),revoke 現有 token 並發行新的 7 天 token。
#### Scenario: 有效 token refresh 成功
- **WHEN** 已登入會員以有效 Bearer token 呼叫 `POST /api/member/refresh`
- **THEN** 回傳 HTTP 200`{ status: true, data: { token, token_type: "Bearer" } }`,舊 token 同時失效
#### Scenario: 無效或過期 token 無法 refresh
- **WHEN** 以過期或無效的 token 呼叫 refresh 端點
- **THEN** 回傳 HTTP 401`{ status: false, message: "無效的 token" }`
#### Scenario: 非 member 角色無法使用 member refresh
- **WHEN** role=provider 的 token 呼叫 `/api/member/refresh`
- **THEN** 回傳 HTTP 403
---
### Requirement: Provider Token Refresh
後端 SHALL 提供 `POST /api/provider/refresh`(需有效 Bearer token),revoke 現有 token 並發行新的 7 天 token。
#### Scenario: 有效 token refresh 成功
- **WHEN** 已登入教練以有效 Bearer token 呼叫 `POST /api/provider/refresh`
- **THEN** 回傳 HTTP 200`{ status: true, data: { token, token_type: "Bearer" } }`,舊 token 同時失效
#### Scenario: 無效或過期 token 無法 refresh
- **WHEN** 以過期或無效的 token 呼叫 refresh 端點
- **THEN** 回傳 HTTP 401
#### Scenario: 非 provider 角色無法使用 provider refresh
- **WHEN** role=member 的 token 呼叫 `/api/provider/refresh`
- **THEN** 回傳 HTTP 403
---
### Requirement: Admin Token Refresh
後端 SHALL 提供 `POST /api/admin/refresh`(需有效 Bearer token),revoke 現有 token 並發行新的 7 天 token。
#### Scenario: 有效 token refresh 成功
- **WHEN** 已登入管理員以有效 Bearer token 呼叫 `POST /api/admin/refresh`
- **THEN** 回傳 HTTP 200`{ status: true, data: { token, token_type: "Bearer" } }`,舊 token 同時失效
#### Scenario: 無效或過期 token 無法 refresh
- **WHEN** 以過期或無效的 token 呼叫 refresh 端點
- **THEN** 回傳 HTTP 401
#### Scenario: 非 admin 角色無法使用 admin refresh
- **WHEN** role=member 或 role=provider 的 token 呼叫 `/api/admin/refresh`
- **THEN** 回傳 HTTP 403
---
### Requirement: 前端 Refresh-Then-Retry 攔截
Member`axios.js`)與 Provider/Coach`coachAxios.js`)的 axios interceptor SHALL 在收到 401 時先嘗試 refresh,成功後以新 token retry 原始請求;refresh 失敗才清除 token 並導向登入頁。同時多個 401 只觸發一次 refresh,其餘請求排隊等待結果。Admin 前端 interceptor 不在此 change 範圍內(Admin Panel 尚未實作)。
#### Scenario: 401 觸發 refresh 並 retry 成功
- **WHEN** API 請求回傳 401 且 refresh 端點回傳新 token
- **THEN** 以新 token 重送原始請求,使用者無感知(不被導向登入頁)
#### Scenario: Refresh 失敗後登出
- **WHEN** API 請求回傳 401 且 refresh 端點也回傳 401
- **THEN** 清除 sessionStorage token,導向登入頁
#### Scenario: 多個並發 401 只觸發一次 refresh
- **WHEN** 同時有多個 API 請求收到 401
- **THEN** 只發出一次 refresh 請求,其他請求等待 refresh 完成後統一以新 token retry
#### Scenario: 登入與 Refresh 端點的 401 不觸發 refresh
- **WHEN** `/login``/register``/refresh` 端點本身回傳 401
- **THEN** 不觸發 refresh,直接將錯誤傳遞給呼叫方(防止無限遞迴)
#### Scenario: 已 retry 的請求不再觸發 refresh
- **WHEN** 某請求已經過一次 refresh-retryretry 後仍回傳 401
- **THEN** 不再嘗試 refresh,直接 reject 並導向登入頁
@@ -0,0 +1,38 @@
## 1. 後端 Refresh 端點
- [x] 1.1 [後端] 在 `app/Http/Controllers/API/AuthController.php` 新增 `refreshMember(Request $request)`:驗證 role=memberrevoke 當前 token,發行新 token,回傳 `{ status: true, data: { token, token_type: "Bearer" } }`
- [x] 1.2 [後端] 同上新增 `refreshProvider(Request $request)`:驗證 role=providerrevoke + 發新 token
- [x] 1.3 [後端] 同上新增 `refreshAdmin(Request $request)`:驗證 role=adminrevoke + 發新 token
- [x] 1.4 [後端] 在 `routes/api.php` 新增三個 refresh 路由(需 auth:sanctum middleware):`POST /api/member/refresh``POST /api/provider/refresh``POST /api/admin/refresh`
- [x] 1.5 [後端] 執行 `php artisan route:clear` 確認路由生效
## 2. 前端 Interceptor 改寫(Member
- [x] 2.1 [前端] 改寫 `frontend/src/api/axios.js`response interceptor 改為 refresh-then-retry 邏輯,加入 `isRefreshing` flag 與 `pendingRequests` queue 防止並發重複 refresh
- [x] 2.2 [前端] 確認 `axios.js` 的 request interceptor 改從 `sessionStorage` 讀取 `token`(非 localStorage
## 3. 前端 Interceptor 改寫(Coach
- [x] 3.1 [前端] 改寫 `frontend/src/api/coachAxios.js`response interceptor 改為 refresh-then-retry,呼叫 `POST /api/provider/refresh`,含 `isRefreshing` + queue 邏輯
- [x] 3.2 [前端] 確認 `coachAxios.js` 的 request interceptor 改從 `sessionStorage` 讀取 `coach_token`
## 4. 前端 Store 改為 sessionStorage
- [x] 4.1 [前端] 修改 `frontend/src/stores/auth.js``init()``setAuth()``logout()` 中所有 `localStorage` 改為 `sessionStorage`keys: `token``user`
- [x] 4.2 [前端] 修改 `frontend/src/stores/coachAuth.js`:同上,keys: `coach_token``coach_user`
- [x] 4.3 [前端] 修改 `frontend/src/views/AuthCallbackView.vue`:第 21 行 `localStorage.setItem('token', token)` 改為 `sessionStorage.setItem('token', token)`
## 5. 自動化測試
- [x] 5.1 [測試] 新增 `tests/Feature/TokenRefreshTest.php`:驗證 `POST /api/member/refresh` 以有效 token 回傳新 token,且舊 token 同時失效(再打一次原 token 回 401)
- [x] 5.2 [測試] 同檔案補充:`/api/provider/refresh``/api/admin/refresh` 相同行為;跨角色呼叫(member token 打 provider/refresh)回 403
- [x] 5.3 [測試] 同檔案補充:過期 token 呼叫 refresh 回 401;已 revoke token 呼叫 refresh 回 401
## 6. 手動驗證
- [x] 6.1 [整合測試] Token 過期行為確認:將 `expires_at` 改成過去時間,打 API 確認被踢回 `/login`refresh 端點同樣受 auth:sanctum 保護,過期 token 無法 refresh,此為預期行為。Sliding window 由 7 天有效期自然實現,主動使用期間不會到期)
- [x] 6.2 [整合測試] 多並發 401 只 refresh 一次:DevTools Network 確認只有一個 `/refresh` 請求,其他請求等待後以新 token 完成
- [x] 6.3 [整合測試] Refresh 失敗全部請求正確 rejectrevoke 所有 token 後觸發 API,確認被踢回登入頁且沒有無限 loop
- [x] 6.4 [整合測試] OAuth callback 改為 sessionStorageGoogle 登入後用 DevTools Application 確認 token 在 sessionStorage(非 localStorage
- [x] 6.5 [整合測試] 關閉分頁後 token 消失:登入後關閉分頁重開,確認 sessionStorage 已清空需重新登入
- [x] 6.6 [整合測試] Role endpoint 對應正確:member token 打 `/api/provider/refresh` 回 403,不會混用
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-02
@@ -0,0 +1,185 @@
## Context
現有防護分兩層:
- **IP throttle**P0):middleware `throttle:5,1` / `throttle:3,1`,防同一 IP 在短時間大量嘗試
- **Session Token**P1):token 7 天過期,sessionStorage 存放,refresh interceptor
缺口一:攻擊者用多 IP 對同一帳號暴破,IP throttle 完全無效。
缺口二:OAuth redirect URL 若遭竄改(CSRF),惡意方可替換 authorization code。
## Goals / Non-Goals
**Goals:**
- 帳號連續失敗 5 次 → 鎖定 15 分鐘,鎖定期間任何密碼登入均拒絕
- 鎖定計數以 Cache 存放(key 包含 `email`),TTL = 鎖定時間
- 登入成功後清除失敗計數
- OAuth redirect 帶 statecallback 驗 state,不符回傳 400
- 前端顯示 423 對應的帳號鎖定訊息
**Non-Goals:**
- Admin 帳號鎖定(Admin Panel 尚未完成)
- 電子郵件通知(鎖定時通知帳號持有人)
- Admin 手動解鎖端點(此 change 暫不實作;時間到自動解鎖即可)
- Lockout 事件 audit log
## Decisions
### D1:失敗計數存 Cachenot DB
**選擇**`Cache::increment()` / `Cache::put()`key = `login_failures:{email}`TTL = 鎖定時間(15 min)。
**理由**
- 不需要永久儲存,TTL = 鎖定時間,到期自動解鎖,零額外維護
- DB 方案需 migration + GC 排程,複雜度高 3 倍
- Cache driver 目前為 filedev/ Redisprod),兩者皆可用
**替代方案**:在 `users` 資料表新增 `failed_login_count` / `locked_until` 欄位 → 否決,因為要 migration,且鎖定狀態不需跨重啟持久化。
### D2:鎖定參數放 config
```php
// config/auth_lockout.php
return [
'max_attempts' => env('LOCKOUT_MAX_ATTEMPTS', 5),
'decay_minutes' => env('LOCKOUT_DECAY_MINUTES', 15),
];
```
**理由**:不同環境(測試 = 100 次)可靠 env 覆蓋,不用改程式碼。
### D3HTTP 423 Locked(帳號鎖定)
**選擇**:回傳 HTTP 423body `{ status: false, message: "帳號已暫時鎖定,請於 X 分鐘後再試", locked_until: "<ISO8601>" }`
**理由**
- 422 / 401 語義不清,423 在 WebDAV 語境下即「資源被鎖定」
- 前端依 423 顯示靜態鎖定提示(`message` 欄位),不實作倒數;`locked_until` timestamp 保留在 response 供未來擴充
### D4OAuth state 存 Laravel Session(不存 Cache
**選擇**`session()->put('oauth_state', $state)` 於 redirectcallback 讀取並 `session()->pull('oauth_state')` 後以 `hash_equals` 比對。
**實作細節**Socialite 的 `redirect()` 內部會自行產生 random state 並寫入 `session('state')`,但 URL 裡已被 `.with(['state' => $state])` 換成我們的 state。若不覆蓋,Socialite 的 `user()` 做內建驗證時會因 session 值與 URL 值不符拋出 `InvalidStateException`。解法:在 `redirect()` 呼叫之後、return 之前,再呼叫 `session()->put('state', $state)` 覆蓋,讓兩層驗證(手動 + Socialite 內建)使用同一 state 值。`stateless()` 因此可完全移除。
**理由**
- state 是 per-request 一次性值,Session 語義完全對應
- 移除 `stateless()` 讓 Socialite 內建驗證也生效,雙重保護
- Cache 方案需自行管理 TTL 與 key 命名,複雜度無謂提升
**替代方案**:PKCE flow → 需要前端配合改 OAuth 啟動方式,改動面太大,此 change 不採用。
### D5:不移除現有 IP throttle
帳號鎖定與 IP throttle 並行:IP throttle 先觸發(429),之後才會累積到帳號鎖定(423)。兩者互不衝突,都保留。
### D6Email 正規化規則
**選擇**:所有 Cache key 使用前,先對 email 執行 `strtolower(trim($email))`
**理由**`User@Gmail.com``user@gmail.com` 在 RFC 5321 語境下是同一信箱,不正規化會導致攻擊者對同一帳號用大小寫變體繞過計數上限。Trim 防止前後空白導致 key 差異。
**範圍**:同規則套用到計數 key、expires_at key、以及查詢 DB 前的 email 比對。Laravel Eloquent `where('email', ...)` 若 DB collation 為 `utf8mb4_unicode_ci` 則大小寫不敏感,但 Cache key 必須額外正規化。
### D7:不存在帳號不累計失敗計數(已確認)
**選擇**:若 DB 查無此 email**不**遞增 Cache 計數,但仍回傳 HTTP 401 並使用與密碼錯誤**相同**的泛用訊息(`"電子郵件或密碼錯誤"`),不揭露帳號是否存在。
**理由**
- 鎖定機制目的是保護真實帳號;不存在的帳號無帳可鎖
- 為不存在帳號累計計數會造成 Cache 污染,且攻擊者可製造大量假 email 讓 Cache 暴增
- 統一 401 訊息確保攻擊者無法透過錯誤回應的差異做帳號枚舉(account enumeration
- 已知殘留風險:攻擊者對真實帳號觸發 5 次失敗後可從 423 推斷帳號存在,但此操作本身已受 IP throttle 限速,實際攻擊成本高
**邊界對照**:帳號不存在 → 401(不遞增);帳號存在但密碼錯 → 401(遞增);帳號已鎖定 → 423;兩種 401 的 response body 訊息相同。
### D8TTL / Window 語義 — Fixed Window from First Failure
**選擇**:採用 **Fixed window**,起算點為「**第一次失敗**」。
具體規則:
1. 查 Cache key `login_failures:{role}:{email}`email 已正規化)
2. 若 key **不存在**count = 0,或 window 已過期):
- `Cache::put('login_failures:{role}:{email}', 1, $decay_minutes * 60)`
- 同時寫入 `login_expires_at:{role}:{email}` = `now()->addMinutes($decay_minutes)->toIso8601String()`TTL 相同
3. 若 key **存在**count ≥ 1):
- `Cache::increment('login_failures:{role}:{email}')`**不重設 TTL**window 不延長)
4. 若遞增後 count ≥ max_attempts:回傳 HTTP 423
**理由**Sliding window(每次失敗延長 TTL)對合法使用者懲罰過重——誤輸密碼後,每一次重試都重置等待時間,實際上永遠解不了鎖。Fixed window 可預測:「最多等 decay_minutes 分鐘」,對使用者友善。
**替代方案**Sliding window → 否決,原因同上。
### D9`locked_until` 的資料來源 — Companion Key
**選擇**:第一次失敗時同步寫入 companion key `login_expires_at:{role}:{email}`,值為 ISO 8601 字串,TTL 與計數 key 相同。每次回傳 HTTP 423 時讀取此 key 作為 `locked_until` 欄位值。
**理由**
- Laravel Cache 不保證所有 driver 提供 `getExpiry()` APIfile driver 無此方法)
- Companion key 方案不依賴 driver 實作細節,跨 file / Redis driver 行為一致
- 兩個 key 的 TTL 相同,到期時同步消失,無需額外清理
**Fallback**:若 `login_expires_at` key 因 driver 不一致或 race condition 缺失,fallback 為 `now()->addMinutes($decay_minutes)->toIso8601String()` 並寫 `Log::warning`
### D10Cache Key Namespace 正式定義
**完整 key 格式**
| Key 用途 | 格式 | 有效 role 值 |
|---------|------|------------|
| 失敗計數 | `login_failures:{role}:{normalized_email}` | `member``provider` |
| 鎖定到期時間 | `login_expires_at:{role}:{normalized_email}` | `member``provider` |
`{role}` 的值與 API 路徑對應:`/api/member/login``member``/api/provider/login``provider``admin` 明確不在此 change 範圍,不建立任何相關 key。
`{normalized_email}` = `strtolower(trim($request->email))`,於 controller 頂部完成正規化,後續所有 Cache 操作一律使用正規化後的值。
### D11:登入端點 Response Semantics 完整對照表
以下為 Member / Provider 登入端點所有可能回應的**完整定義**,用於消除實作端的歧義:
| 情境 | 檢查順序 | HTTP | Response Body |
|------|---------|------|---------------|
| 帳號已在目前 lockout window 內(lockout active | 1st | 423 | `{ status: false, message: "帳號已暫時鎖定,請於 N 分鐘後再試", locked_until: "<ISO8601>" }` |
| 帳號不存在(DB 查無 email | 2nd | 401 | `{ status: false, message: "電子郵件或密碼錯誤" }` |
| 帳號存在,密碼錯誤,失敗 1–4 次(未達閾值) | 3rd | 401 | `{ status: false, message: "電子郵件或密碼錯誤" }` |
| 帳號存在,密碼錯誤,**第 5 次**(觸發鎖定) | 3rd | 423 | `{ status: false, message: "帳號已暫時鎖定,請於 N 分鐘後再試", locked_until: "<ISO8601>" }` |
| 帳號存在,密碼正確 | 3rd | 200 | `{ status: true, data: { token: "...", user: {...} } }` |
**檢查順序說明**
1. 先查 Cache 判斷是否已鎖定(最快路徑,不進 DB)
2. 再查 DB 確認帳號存在(帳號不存在 → 401,不遞增計數)
3. 最後驗證密碼(錯誤 → 遞增計數 → 若達閾值回 423,否則 401;正確 → 200 + 清計數)
`message` 中的 `N` 為靜態設定值(`decay_minutes`),不是即時計算的剩餘分鐘數。前端**不實作**剩餘時間倒數;`locked_until` 欄位保留於 response 以備未來若需精確倒數時使用。
### D12OAuth 多分頁並發 Flow 的預期行為
**選擇**:同一瀏覽器同一 session 下,**只有最後一個啟動的 OAuth flow 能成功**;先啟動的 tab 的 callback 因 state 不符而收到 HTTP 400,前端引導使用者重新操作。此行為為**設計預期**,不視為 bug。
**理由**
- Laravel session 在同一瀏覽器共用(相同 session cookie),後寫的 `oauth_state` 覆蓋先寫的
- 支援多個並發 state 需在 session 中維護 state 陣列(push/find/remove),複雜度不符現階段需求
- 使用者實際上幾乎不會在兩個 tab 同時進行 Google OAuth;若發生,400 → redirect `/login?error=oauth_failed` 是合理的 UX 引導
**替代方案**Session 存 state 陣列(每次 redirect 前 pushcallback 時 find & remove)→ 否決,目前無真實使用場景,過度設計。
## Risks / Trade-offs
- **[DoS 風險] 攻擊者刻意鎖定合法帳號** → 攻擊者只要知道 email 就能讓帳號鎖 15 分鐘。緩解:IP throttle 已先擋,15 分鐘鎖定時間短,影響有限;金流上線前再評估是否加 CAPTCHA。
- **[Account Enumeration Trade-off] 不存在帳號不累計失敗計數** → 可避免攻擊者以大量假 email 汙染 Cache,但攻擊者理論上仍可透過 repeated attempts 是否最終進入 423,間接推測帳號存在與否。本 change 接受此風險,先以統一 401 訊息與既有 IP throttle 緩解;若未來威脅模型提高,再評估改為對不存在帳號也累計或加入 CAPTCHA。
- **[Cache 遺失] file cache 重啟後失效** → 鎖定計數不持久,重啟即解鎖。生產環境預設使用 Redis(`.env` `CACHE_DRIVER=redis`),計數跨容器共享且重啟不失效;file cache 可運作但效果較差(計數不跨容器、重啟清零),僅適用於 dev 環境。
- **[Session 相容] 移除 stateless() 後需 session driver 正常運作** → Docker 環境 session driver 使用 file,確認 storage/framework/sessions 有寫入權限即可。
## Migration Plan
1. 部署時無 DB migrationCache 方案不需 migration
2. **生產環境** `.env` 確認 `CACHE_DRIVER=redis`(Redis 為生產預設,確保計數跨容器共享)
3. `.env` 可選加:`LOCKOUT_MAX_ATTEMPTS=5``LOCKOUT_DECAY_MINUTES=15`(有預設值,不加也可)
4. OAuth state 改動:session driver 已在 dev 正常使用,無需額外設定
5. Rollback:移除兩個 feature 的 controller 邏輯,恢復 `stateless()`;不需 DB rollback
## Open Questions
~~是否在前端 LoginView 顯示「剩餘鎖定時間」倒數?~~ → **已決定**:不實作倒數,只顯示靜態 `message``locked_until` 保留於 response 供未來擴充。
~~生產環境 Cache driver 是否確定為 Redis~~ → **已決定**:生產環境預設 Redis`CACHE_DRIVER=redis`);file cache 可運作但效果較差,不建議用於生產。
@@ -0,0 +1,38 @@
## Why
IP-based throttleP0 已實作)只能防單一 IP 暴力攻擊,無法阻擋分散式密碼暴破(攻擊者用多 IP 對同一帳號嘗試);OAuth 流程目前以 `stateless()` 繞過 CSRF 驗證,存在被偽造授權碼的風險。在金流整合上線前先補齊這兩個帳號層防護,可大幅降低帳號接管與 OAuth CSRF 風險。
## What Changes
- **新增帳號層鎖定機制**Member / Provider 登入連續失敗 N 次後,鎖定該帳號(而非 IP),鎖定期間回傳 HTTP 423;鎖定計數以 Laravel Cache 儲存,N 分鐘後自動解鎖。
- **新增 OAuth state 驗證**`SocialAuthController` 產生隨機 state 字串存入 sessioncallback 時驗證 state 是否吻合;不吻合時拒絕並回傳 HTTP 400;移除 `stateless()` 呼叫。
## Capabilities
### New Capabilities
- `account-lockout`:帳號層連續失敗鎖定,覆蓋 Member / Provider 登入流程(Admin 登入失敗計數另行討論,暫不列入)
- `oauth-state-validation`OAuth CSRF 防護,補 state parameter 生成與驗證,適用 Member Google OAuth 流程
### Modified Capabilities
- `login-rate-limiting`:無需變更需求(IP throttle 與帳號鎖定並行,各自獨立,Requirement 不衝突)
## Impact
**後端**
- `app/Http/Controllers/API/AuthController.php`Member login
- `app/Http/Controllers/API/ProviderAuthController.php`Provider login
- `app/Http/Controllers/API/SocialAuthController.php`OAuth redirect / callback
- `routes/api.php`(可能新增解鎖端點)
- `config/auth_lockout.php`(鎖定參數:最大失敗次數、鎖定時間)
- Laravel Cache(驗證失敗計數,driver 使用現有 Redis 或 file
**前端**
- `src/views/LoginView.vue` / `CoachLoginView.vue`:顯示「帳號已鎖定,請稍後再試」錯誤訊息(HTTP 423 對應)
- `src/plugins/echo.js` / `src/api/axios.js`:無需變更(OAuth callback 不走 Axios
- `src/views/AuthCallbackView.vue`:無需變更(後端回傳 400 時 redirect 到 login 並帶錯誤訊息)
**不影響**
- 現有 Sanctum token、sessionStorage、refresh interceptor
- Admin PanelAdmin 登入鎖定不在此 change 範圍)
@@ -0,0 +1,104 @@
## ADDED Requirements
### Requirement: Cache Key Namespace
後端 SHALL 使用以下格式的 Cache key`{role}` 僅允許 `member``provider` 兩個值,對應各自的登入端點;`{email}``strtolower(trim($request->email))` 正規化後的結果。
| Key 用途 | 格式 |
|---------|------|
| 失敗計數 | `login_failures:{role}:{email}` |
| 鎖定到期時間 | `login_expires_at:{role}:{email}` |
#### Scenario: role 值與端點對應
- **WHEN** Member 登入失敗
- **THEN** 使用 key `login_failures:member:{email}``login_expires_at:member:{email}`
#### Scenario: Provider 與 Member 計數互相隔離
- **WHEN** 同一個 email 在 Member 端點失敗 4 次,再到 Provider 端點失敗 4 次
- **THEN** 兩個 role namespace 各自獨立,均未達閾值,均不觸發鎖定
### Requirement: 帳號層登入失敗鎖定
後端 SHALL 追蹤每個帳號(以正規化 email 為 key)的登入失敗次數。計數採用 **Fixed Window**:第一次失敗時建立 Cache key 並設 TTL = `decay_minutes * 60` 秒,後續失敗只遞增計數而**不重設 TTL**(window 不延長)。當失敗次數達到閾值(預設 5 次)時,系統 SHALL 鎖定該帳號,鎖定期間任何密碼登入 SHALL 被拒絕並回傳 HTTP 423(含 `locked_until` ISO 8601 欄位),直到 Cache key 自然過期。`locked_until` 的值來源為第一次失敗時同步寫入的 companion key `login_expires_at:{role}:{email}`
**登入端點完整 Response Semantics**(按後端處理順序):
| 情境 | 檢查順序 | HTTP | Response Body 重點欄位 |
|------|---------|------|----------------------|
| 帳號已鎖定(Cache 計數 ≥ max_attempts | 1st | 423 | `message: "帳號已暫時鎖定..."`, `locked_until: "<ISO8601>"` |
| 帳號不存在(DB 查無 email | 2nd | 401 | `message: "電子郵件或密碼錯誤"` |
| 帳號存在,密碼錯,失敗 1–4 次 | 3rd | 401 | `message: "電子郵件或密碼錯誤"` |
| 帳號存在,密碼錯,**第 5 次**(當場觸發鎖定) | 3rd | 423 | `message: "帳號已暫時鎖定..."`, `locked_until: "<ISO8601>"` |
| 帳號存在,密碼正確 | 3rd | 200 | `token`, `user` |
「帳號不存在」與「密碼錯誤 1–4 次」的 HTTP status 和 message SHALL 完全相同,不得有任何可區分的差異(防帳號枚舉)。
#### Scenario: 未達閾值的登入失敗
- **WHEN** 帳號在 15 分鐘內累計失敗次數少於 5 次
- **THEN** 系統正常回傳 HTTP 401`failed_attempts` 計數遞增,帳號不鎖定
#### Scenario: 第 5 次失敗觸發鎖定
- **WHEN** 帳號在 15 分鐘內第 5 次登入失敗
- **THEN** 系統回傳 HTTP 423body 包含 `{ status: false, message: "帳號已暫時鎖定,請於 15 分鐘後再試", locked_until: "<ISO8601 timestamp>" }`,後續任何密碼登入均被拒絕直到時間到期
#### Scenario: 鎖定期間嘗試登入
- **WHEN** 帳號處於鎖定狀態,使用者送出任何 email/password 組合
- **THEN** 系統不驗證密碼,直接回傳 HTTP 423 並附帶 `locked_until` 欄位
#### Scenario: 登入成功後清除失敗計數
- **WHEN** 使用者在未達閾值前成功登入
- **THEN** 系統清除該帳號的失敗計數,下次失敗從 0 重新累積
#### Scenario: 鎖定時間到期自動解鎖
- **WHEN** 帳號鎖定後超過 15 分鐘未有新登入嘗試
- **THEN** 鎖定計數自動過期(Cache TTL),帳號恢復可登入狀態
#### Scenario: 鎖定機制覆蓋 Member 與 Provider
- **WHEN** Member`/api/member/login`)或 Provider`/api/provider/login`)觸發連續失敗
- **THEN** 各自以 email 為 key 獨立計數,互不干擾
#### Scenario: Fixed window — 失敗不延長鎖定時間
- **WHEN** 帳號在 window 內第 3 次失敗(尚未達閾值),之後又再失敗 1 次
- **THEN** TTL **不重設**`locked_until` 仍為第一次失敗時計算的到期時間
#### Scenario: `locked_until` 來自 companion key
- **WHEN** 系統回傳 HTTP 423
- **THEN** response body 的 `locked_until` 欄位值等於 `login_expires_at:{role}:{email}` Cache key 的儲存值(ISO 8601),即第一次失敗時計算的 `now + decay_minutes`
### Requirement: 不存在帳號不累計失敗計數
後端 SHALL 在遞增失敗計數**前**先查詢 DB 確認帳號是否存在。若查無此 email,SHALL 直接回傳 HTTP 401,訊息與密碼錯誤**相同**(`"電子郵件或密碼錯誤"`),**不**遞增任何 Cache 計數。
#### Scenario: 不存在帳號登入失敗
- **WHEN** 使用者以一個 DB 中不存在的 email 嘗試登入
- **THEN** 系統回傳 HTTP 401body `{ status: false, message: "電子郵件或密碼錯誤" }`,Cache 中**不建立也不遞增**任何此 email 的失敗計數
#### Scenario: 錯誤訊息無法區分帳號不存在與密碼錯誤
- **WHEN** 帳號不存在 vs. 帳號存在但密碼錯誤,兩種情境均發生
- **THEN** 兩者 HTTP status 相同(401),response body 訊息相同,攻擊者無法透過回應差異做帳號枚舉
### Requirement: Email 正規化
後端 SHALL 在所有涉及鎖定計數的操作前,對 email 執行 `strtolower(trim($email))`,確保大小寫變體與前後空白不影響計數的正確性。
#### Scenario: Email 大小寫變體視為同一帳號
- **WHEN** 攻擊者用 `User@Gmail.com``user@gmail.com``USER@GMAIL.COM` 依序嘗試登入同一帳號
- **THEN** 三次嘗試累計到同一個計數 key,共累計 3 次失敗
### Requirement: 鎖定閾值可由環境變數設定
後端 SHALL 從 `config/auth_lockout.php` 讀取鎖定參數,允許透過 `.env` 覆蓋 `LOCKOUT_MAX_ATTEMPTS`(預設 5)與 `LOCKOUT_DECAY_MINUTES`(預設 15)。
#### Scenario: 使用預設值
- **WHEN** `.env` 未設定 `LOCKOUT_MAX_ATTEMPTS` / `LOCKOUT_DECAY_MINUTES`
- **THEN** 系統使用 5 次 / 15 分鐘作為鎖定參數
#### Scenario: 透過 env 覆蓋閾值
- **WHEN** `.env` 設定 `LOCKOUT_MAX_ATTEMPTS=10`
- **THEN** 系統以 10 次失敗後才鎖定
### Requirement: 前端顯示帳號鎖定訊息
前端 SHALL 在接收到 HTTP 423 時,顯示「帳號已暫時鎖定」的明確提示,而非泛用的「帳密錯誤」訊息。
#### Scenario: Member 登入頁收到 423
- **WHEN** `LoginView.vue` 的登入請求收到 HTTP 423
- **THEN** 顯示 response body 中的 `message` 欄位內容(如「帳號已暫時鎖定,請於 15 分鐘後再試」)
#### Scenario: Provider 登入頁收到 423
- **WHEN** `CoachLoginView.vue` 的登入請求收到 HTTP 423
- **THEN** 同上,顯示鎖定提示訊息
@@ -0,0 +1,35 @@
## ADDED Requirements
### Requirement: OAuth 授權流程帶 state 參數防 CSRF
後端 SHALL 在 OAuth redirect 前產生隨機 state 字串(至少 32 bytes hex),存入 `session('oauth_state')`,並附加於 OAuth redirect URL 的 `state` query parameter。`stateless()` 呼叫 SHALL 被移除;Socialite 在 `redirect()` 內會自行寫入 `session('state')`,實作 SHALL 在 `redirect()` 呼叫後以同一 state 值覆蓋 `session('state')`,確保 Socialite 內建驗證與手動驗證使用相同值。callback 時 SHALL 從 request 讀取 `state` 並與 `session('oauth_state')` 比對(`hash_equals`);比對成功後 SHALL 立即清除;不符或缺少時 SHALL redirect 至 `/login?error=oauth_failed`
#### Scenario: 正常 OAuth 登入流程
- **WHEN** 使用者點擊「Google 登入」,後端發出 redirect
- **THEN** redirect URL 包含 `state=<random>` 參數,且相同 random 值存入 session;使用者在 Google 完成授權後,callback 帶回相同 state,後端驗證通過,繼續正常登入流程
#### Scenario: state 缺失(直接訪問 callback
- **WHEN** 攻擊者或使用者直接訪問 `/auth/google/callback` 而不帶 `state` 參數
- **THEN** 後端 redirect 瀏覽器至 `{frontend_url}/login?error=oauth_failed`,不繼續登入流程
#### Scenario: state 不符(CSRF 攻擊)
- **WHEN** callback 帶的 `state``session('oauth_state')` 中的值不一致
- **THEN** 後端 redirect 瀏覽器至 `{frontend_url}/login?error=oauth_failed`,不繼續登入流程
#### Scenario: state 只能使用一次
- **WHEN** callback 驗證 state 成功後(`session()->pull('oauth_state')` 已清除),再次以相同 state 訪問 callback
- **THEN** session 中已無 `oauth_state`,視為 state 缺失,同樣 redirect 至 `/login?error=oauth_failed`
#### Scenario: 登入頁顯示 OAuth 失敗訊息
- **WHEN** 瀏覽器被 redirect 至 `/login?error=oauth_failed`
- **THEN** `LoginView.vue` 偵測到 `route.query.error === 'oauth_failed'`,顯示「OAuth 授權失敗,請重新嘗試」提示;`AuthCallbackView.vue` 不涉及此錯誤流程
#### Scenario: 多分頁並發 OAuth flow — 只有最後啟動的成功(預期行為)
- **WHEN** 使用者在同一瀏覽器開兩個 tab,Tab A 先啟動 OAuthsession 寫入 state_A),Tab B 後啟動 OAuthsession 覆寫為 state_B),Tab A 的 callback 先到達後端
- **THEN** Tab A 的 callback 帶 state_A,與 session 中的 state_B 不符,後端 redirect 至 `/login?error=oauth_failed`Tab B 的 callback 帶 state_B,比對成功,正常完成登入
### Requirement: OAuth session driver 正常運作
後端 SHALL 確保 session driver 在 Docker 環境中可寫入(`storage/framework/sessions` 目錄存在且有寫入權限),以支援 state 存取。
#### Scenario: Session 寫入成功
- **WHEN** OAuth redirect 觸發時
- **THEN** state 值成功寫入 session,無 500 錯誤
@@ -0,0 +1,36 @@
## 1. 帳號鎖定設定檔與共用 Helper
- [x] 1.1 [後端] 建立 `config/auth_lockout.php`,定義 `max_attempts`env `LOCKOUT_MAX_ATTEMPTS`, 預設 5)與 `decay_minutes`env `LOCKOUT_DECAY_MINUTES`, 預設 15
- [x] 1.2 [後端] 建立 `app/Traits/NormalizesEmail.php` Trait,定義 `normalizeEmail(string $email): string``strtolower(trim($email))`),在 `AuthController``ProviderAuthController` 均 use 此 Trait
## 2. Member 帳號鎖定邏輯
- [x] 2.1 [後端] 修改 `app/Http/Controllers/API/AuthController.php``loginMember()`:對 request email 執行 `strtolower(trim())` 正規化後,先查 Cache key `login_failures:member:{email}`,若已達閾值讀取 `login_expires_at:member:{email}` 回傳 HTTP 423(含 `locked_until`
- [x] 2.2 [後端] DB 查無帳號時直接回傳 HTTP 401(訊息與密碼錯誤相同),**不**遞增計數
- [x] 2.3 [後端] 登入失敗(帳號存在但密碼錯)時:若 `login_failures` key 不存在則用 `Cache::put()` 設初始值 1 並同步寫入 `login_expires_at` companion keyTTL 相同);若 key 已存在則只 `Cache::increment()`(不重設 TTL);回傳 423 時讀取 `login_expires_at` key,若缺失則 fallback 為 `now()->addMinutes($decay_minutes)` 並寫 `Log::warning`
- [x] 2.4 [後端] 登入成功時 `Cache::forget('login_failures:member:{email}')``Cache::forget('login_expires_at:member:{email}')`
## 3. Provider 帳號鎖定邏輯
- [x] 3.1 [後端] 修改 `app/Http/Controllers/API/ProviderAuthController.php``loginProvider()`:同 Member 邏輯(email 正規化、不存在帳號不遞增、companion key、fixed window),Cache key 前綴改為 `login_failures:provider:` / `login_expires_at:provider:`
- [x] 3.2 [後端] 登入成功時清除兩個 Cache keyfailures + expires_at
## 4. OAuth State 驗證
- [x] 4.1 [後端] 修改 `app/Http/Controllers/API/SocialAuthController.php``redirectToProvider()`:移除 `stateless()`,產生 `bin2hex(random_bytes(32))``session('oauth_state')`,附加 `state` 於 redirect URL
- [x] 4.2 [後端] 修改 `handleProviderCallback()`:移除 `stateless()`,讀取 `request->state``session('oauth_state')` 比對;不符或缺失時回傳 HTTP 400;比對後立即 `session()->forget('oauth_state')`
## 5. 前端錯誤處理
- [x] 5.1 [前端] 修改 `frontend/src/views/LoginView.vue`axios catch 中判斷 `error.response?.status === 423`,顯示 `response.data.message`(帳號鎖定提示),而非泛用錯誤
- [x] 5.2 [前端] 修改 `frontend/src/views/CoachLoginView.vue`:同上,處理 423 狀態碼
- [x] 5.3 [前端] 修改 `frontend/src/views/AuthCallbackView.vue`:當後端 OAuth callback 回傳 400 時,redirect 至 `/login?error=oauth_failed`
- [x] 5.4 [前端] 修改 `frontend/src/views/LoginView.vue`:若 `route.query.error === 'oauth_failed'`,顯示「OAuth 授權失敗,請重新嘗試」提示
## 6. 整合驗證
- [x] 6.1 [整合測試] 手動測試 Member 登入:連續失敗 5 次後收到 423,等待 15 分鐘後或改正確密碼(需清除 Cache)後可登入
- [x] 6.2 [整合測試] 手動測試 Provider 登入:同上
- [x] 6.3 [整合測試] 手動測試 OAuth:正常流程可完成;直接訪問 callback URL(無 state)收到 400 並 redirect 至 `/login?error=oauth_failed`
- [x] 6.4 [整合測試] 確認 `storage/framework/sessions` 目錄在 Docker 容器中有寫入權限(session 存 state 不報錯)
- [x] 6.5 [部署] 確認生產環境 `.env` 設定 `CACHE_DRIVER=redis`;驗證方式:`php artisan tinker``Cache::put('test', 1, 10); Cache::get('test');` 回傳 1 即 Redis 正常
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-07
@@ -0,0 +1,60 @@
## Context
Auth 模組已實作三角色(member / provider / admin)的登入、註冊、登出,以及 P2 安全強化(帳號鎖定、OAuth state 驗證)。目前 `tests/Feature/` 只有 `AuthRateLimitTest`throttle)和 `TokenRefreshTest`refresh token),主流程與安全邏輯完全無覆蓋。
測試環境:`phpunit.xml` 已設定 `DB_CONNECTION=sqlite``DB_DATABASE=:memory:`Cache 使用 `array` driverQueue 使用 `sync`
## Goals / Non-Goals
**Goals:**
- 補齊登入/註冊/登出三角色的 happy path 與 guard 測試
- 覆蓋 P2 帳號鎖定邏輯(Fixed Window、Email 正規化、role 隔離)
- 覆蓋 P2 OAuth state 驗證(state 缺失 / 不符 / 正確)
- 每個測試檔案獨立、可單獨執行
**Non-Goals:**
- 不測試 Token Refresh(已有 `TokenRefreshTest`
- 不測試 Rate Limiting(已有 `AuthRateLimitTest`
- 不測試前端 Vue 元件
- 不測試 Admin CRUD(屬於別的 change 範疇)
## Decisions
### 決策 1:三支測試檔案,各自對應一個關注點
- `AuthLoginTest.php`:登入 / 註冊 / 登出三角色的 happy path + guard
- `AuthLockoutTest.php`P2 帳號鎖定邏輯
- `AuthOAuthTest.php`P2 OAuth state 驗證
**理由**:單一檔案職責清晰,出錯時可快速定位;不同測試的 setUp 需求差異大(Lockout 需 Cache::flush、OAuth 需 Socialite mock)。
### 決策 2Lockout 測試使用 Cache facade,不 mock
phpunit.xml 已設 `CACHE_STORE=array`Cache 行為完全真實。
`setUp()` 呼叫 `Cache::flush()` 確保測試間隔離。
**理由**Lockout 邏輯的 bug 通常出在 Cache key 命名或 TTL 操作,用真實 array cache 才能抓到;mock 只會讓測試通過而 bug 繼續存在。
### 決策 3OAuth 測試使用 Socialite mock,並以 shouldNotReceive 驗證安全不變式
OAuth callback 依賴 Google 回應,測試環境無法真實呼叫,使用 Socialite Facade mock。
Mock 在 OAuth 測試中有兩個用途:
- **state 正確時**`shouldReceive('driver->user')` 回傳假 Google user,讓登入流程走完
- **state 缺失 / 不符時**`shouldNotReceive('driver->user')` 確認 Socialite 不被呼叫
第二個用途是這組測試的核心安全不變式:**CSRF 檢查失敗時,系統不得向 OAuth provider 取 user**。如果只斷言 redirect URL 含 `error=oauth_failed` 而不驗證 Socialite 未被呼叫,測試無法偵測「state 檢查失敗但仍繼續執行 OAuth 流程」這類回歸。
**理由**OAuth state 的設計意圖不只是「最後回錯誤」,而是在 CSRF 檢查點提前中止整個流程;`shouldNotReceive` 是唯一能驗證這個 invariant 的手段。
### 決策 4:共用 helper 方法直接定義在各測試 class 內
三支測試各自有 `createMember()``createProvider()``createAdmin()` 等 helper。
**理由**:測試共 3 個檔案,規模不大,不值得抽 BaseTestCase。一旦需要改 helper 邏輯,在各自檔案改更直觀,不會影響其他測試。
## Risks / Trade-offs
- **OAuth redirect 比對**`handleGoogleCallback` 最終用 `redirect()` 回前端 URL,測試需用 `assertRedirect()` 而非 `assertStatus()`URL 中含 `error=oauth_failed` 做斷言。→ 使用 `assertRedirectContains('error=oauth_failed')` 避免硬寫前端 URL。
- **Lockout TTL 在測試中**array cache 不適合可靠驗證時間流逝後的自動過期,因此不測 cache driver 的 TTL 行為本身。測試只驗證「當 lockout cache entry 不存在時,登入流程恢復允許」;清除狀態以 `Cache::forget()` 模擬,不等待真實時間。這不等同於「TTL 到期自動解鎖」的驗證,兩者不可混淆。
- **Admin 帳號在鎖定測試的範圍**AuthController 的 `loginAdmin` 沒有帳號鎖定邏輯(P2 只加了 member/provider),不在 AuthLockoutTest 測試範圍。→ 在 AuthLoginTest 只測 Admin 的 happy path 與角色 guard。
## Open Questions
- 無(設計已足夠完整開始實作)
@@ -0,0 +1,25 @@
## Why
Auth 模組(登入/註冊/登出、P2 帳號鎖定、P2 OAuth state 驗證)已實作並上線,但目前完全沒有測試覆蓋登入主流程與安全邏輯,任何回歸都會靜默失效。
## What Changes
- 新增 `tests/Feature/AuthLoginTest.php`:覆蓋 member / provider / admin 三角色的登入、註冊、登出、非活躍帳號拒絕、角色隔離等場景
- 新增 `tests/Feature/AuthLockoutTest.php`:覆蓋 P2 帳號鎖定的全部 scenarioFixed Window 計數、第 5 次觸發、鎖定期間拒絕、成功後清除、Email 正規化、role 隔離)
- 新增 `tests/Feature/AuthOAuthTest.php`:覆蓋 P2 OAuth state 驗證(state 不符回 `/login?error=oauth_failed`、state 正確完成登入)
## Capabilities
### New Capabilities
- `auth-test-coverage`:定義 Auth 模組所有必要測試場景的規格,作為實作三支測試檔案的依據
### Modified Capabilities
(無,所有需求已定義於既有 spec;本 change 只新增驗證覆蓋)
## Impact
- 僅新增測試檔案,不修改任何 production 程式碼
- 依賴:`phpunit.xml` 已設定 `DB_CONNECTION=sqlite``DB_DATABASE=:memory:`,可直接使用
- 影響範圍:`tests/Feature/`(新增三個檔案)
@@ -0,0 +1,151 @@
## ADDED Requirements
### Requirement: Auth 登入/註冊/登出測試覆蓋(三角色)
測試套件 SHALL 對 member、provider、admin 三個角色各自驗證以下場景,確保回歸時能即時偵測。
#### Scenario: Member 註冊成功
- **WHEN** 送出有效的 name / email / password / password_confirmation 至 `POST /api/member/register`
- **THEN** 回傳 HTTP 201body 包含 `{ status: true, data: { user } }`DB 存在對應 member 用戶
#### Scenario: Member 重複 Email 註冊失敗
- **WHEN** 送出已存在的 email 至 `POST /api/member/register`
- **THEN** 回傳 HTTP 422
#### Scenario: Member 登入成功
- **WHEN** 送出正確的 email / password 至 `POST /api/member/login`
- **THEN** 回傳 HTTP 200body 包含 `{ status: true, data: { token, token_type: "Bearer", user } }`
#### Scenario: Member 登入密碼錯誤
- **WHEN** 送出存在的 email 但錯誤的 password
- **THEN** 回傳 HTTP 401`{ status: false, message: "電子郵件或密碼錯誤" }`
#### Scenario: Member 非活躍帳號拒絕登入
- **WHEN** 送出 is_active=false 帳號的正確帳密
- **THEN** 回傳 HTTP 403
#### Scenario: Provider 帳號無法呼叫 Member 登入
- **WHEN** role=provider 的帳號嘗試 `POST /api/member/login`
- **THEN** 回傳 HTTP 401`{ status: false, message: "電子郵件或密碼錯誤" }`(查詢以 role 過濾,跨角色帳號視同不存在,不洩漏帳號實際所屬角色)
#### Scenario: Member 登出成功
- **WHEN** 已登入 Member 送出 `POST /api/member/logout`(帶有效 Bearer token
- **THEN** 回傳 HTTP 200token 被撤銷(後續以此 token 請求受保護資源應得 401)
#### Scenario: Provider 登出成功
- **WHEN** 已登入 Provider 送出 `POST /api/provider/logout`(帶有效 Bearer token
- **THEN** 回傳 HTTP 200token 被撤銷(後續以此 token 請求受保護資源應得 401)
#### Scenario: Admin 登出成功
- **WHEN** 已登入 Admin 送出 `POST /api/admin/logout`(帶有效 Bearer token
- **THEN** 回傳 HTTP 200token 被撤銷(後續以此 token 請求受保護資源應得 401)
#### Scenario: Provider 帳號無法呼叫 Admin 登入
- **WHEN** role=provider 的帳號嘗試 `POST /api/admin/login`
- **THEN** 回傳 HTTP 401`{ status: false, message: "電子郵件或密碼錯誤" }`(查詢以 role 過濾,跨角色帳號視同不存在)
#### Scenario: Provider 註冊成功
- **WHEN** 送出有效的 name / email / password / password_confirmation 至 `POST /api/provider/register`
- **THEN** 回傳 HTTP 201body 包含 `{ status: true, data: { user } }`DB 存在對應 provider 用戶
#### Scenario: Provider 重複 Email 註冊失敗
- **WHEN** 送出已存在的 email 至 `POST /api/provider/register`
- **THEN** 回傳 HTTP 422
#### Scenario: Provider 登入成功
- **WHEN** 送出正確的 email / password 至 `POST /api/provider/login`
- **THEN** 回傳 HTTP 200body 包含 `{ status: true, data: { token, token_type: "Bearer", user } }`
#### Scenario: Provider 非活躍帳號拒絕登入
- **WHEN** 送出 is_active=false 的 provider 帳號正確帳密
- **THEN** 回傳 HTTP 403
#### Scenario: Member 帳號無法呼叫 Provider 登入
- **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 201body 包含 `{ status: true, data: { user } }`DB 存在對應 admin 用戶
#### Scenario: Admin 重複 Email 註冊失敗
- **WHEN** 送出已存在的 email 至 `POST /api/admin/register`
- **THEN** 回傳 HTTP 422
#### Scenario: Admin 登入成功
- **WHEN** 送出正確的 email / password 至 `POST /api/admin/login`
- **THEN** 回傳 HTTP 200body 包含 `{ status: true, data: { token, token_type: "Bearer", user } }`
#### Scenario: Admin 登入非 admin 帳號失敗
- **WHEN** role=member 帳號嘗試 `POST /api/admin/login`
- **THEN** 回傳 HTTP 401`{ status: false, message: "電子郵件或密碼錯誤" }`(查詢以 role 過濾,跨角色帳號視同不存在)
---
### Requirement: 帳號鎖定測試覆蓋(P2 Fixed Window
測試套件 SHALL 驗證帳號鎖定邏輯的全部關鍵路徑,以防止回歸破壞安全機制。
#### Scenario: 失敗次數未達閾值不鎖定
- **WHEN** 同一帳號連續登入失敗 4 次
- **THEN** 每次均回傳 HTTP 401,帳號未鎖定
#### Scenario: 第 5 次失敗當場觸發鎖定
- **WHEN** 同一帳號連續登入失敗第 5 次
- **THEN** 回傳 HTTP 423body 包含 `{ status: false, locked_until: "<ISO8601>" }`
#### Scenario: 鎖定期間任何登入均被拒絕
- **WHEN** 帳號已鎖定,使用者送出(含正確密碼的)登入請求
- **THEN** 回傳 HTTP 423,不驗證密碼
#### Scenario: 不存在帳號不累計失敗計數
- **WHEN** 以不存在的 email 連續嘗試登入 10 次
- **THEN** 每次均回傳 HTTP 401Cache 中不建立計數 key,第 10 次後帳號不鎖定
#### Scenario: 登入成功後清除失敗計數
- **WHEN** 帳號失敗 3 次後成功登入,再失敗 4 次
- **THEN** 失敗計數從成功後重新累積,第 4 次仍回傳 401(不觸發鎖定)
#### Scenario: Email 大小寫視為同一帳號
- **WHEN** 同一帳號分別用 `user@example.com` 失敗 3 次、`User@Example.COM` 失敗 2 次
- **THEN** 第 5 次失敗(累計)觸發鎖定,回傳 HTTP 423
#### Scenario: Email 前後空白視為同一帳號
- **WHEN** 同一帳號分別用 `user@example.com` 失敗 3 次、` user@example.com `(含前後空白)失敗 2 次
- **THEN** 第 5 次失敗(累計)觸發鎖定,回傳 HTTP 423
#### Scenario: 鎖定 cache entry 不存在時帳號恢復可登入
- **WHEN** 帳號已鎖定,鎖定的 cache entry 被移除(模擬 TTL 自然過期)
- **THEN** 帳號可正常登入,回傳 HTTP 200
#### Scenario: 跨角色嘗試不會汙染或鎖定正確角色帳號
> 註:原情境假設「同一 email 同時擁有 member 與 provider 帳號」,但 `users.email` 在 DB 層級為全域 unique`database/migrations/2025_05_06_065906_create_orgin_table.php:18`),此前提不可能成立,故改寫為下列可達成、且驗證相同安全性質(角色間鎖定計數互相隔離)的版本。
- **WHEN** 一個僅存在 provider 帳號的 email,先對 `/api/member/login` 送出 4 次失敗嘗試,再對 `/api/provider/login` 送出 4 次失敗嘗試
- **THEN** member namespace 不建立計數 key(帳號不存在於該 namespace);provider namespace 計數累積為 4、未達閾值不鎖定;provider 帳號仍可用正確密碼正常登入
#### Scenario: Fixed Window — 失敗不延長 TTL
- **WHEN** 帳號失敗 4 次後,第 4 次失敗發生於 TTL 將到期前
- **THEN** Cache TTL 不被重設,`locked_until` 仍為第一次失敗時寫入的時間
#### Scenario: `locked_until` 來自 companion key
- **WHEN** 帳號觸發鎖定,回傳 HTTP 423
- **THEN** response body 的 `locked_until` 等於 `login_expires_at:{role}:{email}` cache key 的值
---
### Requirement: OAuth State 驗證測試覆蓋(P2
測試套件 SHALL 驗證 OAuth callback 的 state 比對邏輯,涵蓋正常、缺失、不符三種情境。
#### Scenario: State 缺失時 redirect 至錯誤頁,且不呼叫 Socialite
- **WHEN** 直接呼叫 `GET /auth/google/callback` 而不帶 `state` 參數(session 無 `oauth_state`
- **THEN** 後端 redirect 至包含 `error=oauth_failed` 的 URL,不繼續登入流程;Socialite 的 `user()` 不被呼叫
#### Scenario: State 不符時 redirect 至錯誤頁,且不呼叫 Socialite
- **WHEN** callback 帶 `state=wrong_value`,但 session 中 `oauth_state` 為不同值
- **THEN** 後端 redirect 至包含 `error=oauth_failed` 的 URLSocialite 的 `user()` 不被呼叫
#### Scenario: State 正確時完成 OAuth 登入
- **WHEN** callback 帶的 `state` 與 session 中 `oauth_state` 一致,且 Socialite 回傳有效 Google user
- **THEN** 後端完成登入,redirect 至前端並附帶 tokenURL fragment `#token=...`
#### Scenario: State 成功驗證後從 session 消耗
- **WHEN** callback 帶正確 `state` 完成一次登入後,再次以相同 `state` 呼叫 callback
- **THEN** session 中已無 `oauth_state`,視為 state 缺失,redirect 至 `error=oauth_failed`
@@ -0,0 +1,56 @@
## 1. AuthLoginTest — 三角色登入/註冊/登出
- [x] 1.1 [整合測試] 建立 `tests/Feature/AuthLoginTest.php`,加入 `RefreshDatabase` trait 與 helper methods`createMember``createProvider``createAdmin`
- [x] 1.2 [整合測試] 實作 `test_member_register_success`POST `/api/member/register` 回傳 201 + DB 有對應 member 用戶
- [x] 1.3 [整合測試] 實作 `test_member_register_duplicate_email_returns_422`:重複 email 回傳 422
- [x] 1.4 [整合測試] 實作 `test_member_login_success`:正確帳密回傳 200 + token
- [x] 1.5 [整合測試] 實作 `test_member_login_wrong_password_returns_401`:錯誤密碼回傳 401
- [x] 1.6 [整合測試] 實作 `test_member_login_inactive_account_returns_403`is_active=false 回傳 403
- [x] 1.7 [整合測試] 實作 `test_provider_account_cannot_use_member_login`role=provider 帳號打 member login 回傳 401(查詢以 role 過濾,跨角色視同帳號不存在)
- [x] 1.8 [整合測試] 實作 `test_member_logout_revokes_token`:登出後 token 失效(DB 確認 token 已刪除),且以同一 token 再打 `/api/member/profile` 得 401(需先 `Auth::forgetGuards()` 清除 `RequestGuard` 在同一測試內快取的已解析 user,否則會讀到快取而誤判為 200)
- [x] 1.9 [整合測試] 實作 `test_provider_logout_revokes_token`Provider 登出後 token 失效,且以同一 token 再打 `/api/provider/profile` 得 401(同樣需 `Auth::forgetGuards()`
- [x] 1.10 [整合測試] 實作 `test_admin_logout_revokes_token`Admin 登出後 token 失效,且以同一 token 再打 `/api/admin/profile` 得 401(同樣需 `Auth::forgetGuards()`
- [x] 1.12 [整合測試] 實作 `test_provider_login_success`Provider 正確帳密回傳 200 + token
- [x] 1.13 [整合測試] 實作 `test_provider_login_inactive_account_returns_403`is_active=false 回傳 403
- [x] 1.14 [整合測試] 實作 `test_member_account_cannot_use_provider_login`role=member 帳號打 provider login 回傳 401(查詢以 role 過濾,跨角色視同帳號不存在)
- [x] 1.15 [整合測試] 實作 `test_admin_login_success`Admin 正確帳密回傳 200 + token
- [x] 1.16 [整合測試] 實作 `test_member_cannot_use_admin_login`role=member 帳號打 admin login 回傳 401(查詢以 role 過濾,跨角色視同帳號不存在)
- [x] 1.17 [整合測試] 實作 `test_provider_cannot_use_admin_login`role=provider 帳號打 admin login 回傳 401(查詢以 role 過濾,跨角色視同帳號不存在)
- [x] 1.18 [整合測試] 實作 `test_provider_register_success`POST `/api/provider/register` 回傳 201 + DB 有對應 provider 用戶(design.md 載明三角色註冊均須覆蓋,原 1.x 僅含 member 註冊測試,此處補齊)
- [x] 1.19 [整合測試] 實作 `test_provider_register_duplicate_email_returns_422`:重複 email 回傳 422
- [x] 1.20 [整合測試] 實作 `test_admin_register_success`POST `/api/admin/register` 回傳 201 + DB 有對應 admin 用戶
- [x] 1.21 [整合測試] 實作 `test_admin_register_duplicate_email_returns_422`:重複 email 回傳 422
## 2. AuthLockoutTest — P2 帳號鎖定
- [x] 2.1 [整合測試] 建立 `tests/Feature/AuthLockoutTest.php``setUp()` 加入 `Cache::flush()` 確保測試間隔離
- [x] 2.2 [整合測試] 實作 `test_four_failures_do_not_lock_account`:同一帳號失敗 4 次均回傳 401,帳號未鎖定
- [x] 2.3 [整合測試] 實作 `test_fifth_failure_triggers_423_with_locked_until`:第 5 次失敗回傳 423,body 含 `locked_until` ISO 8601 欄位
- [x] 2.4 [整合測試] 實作 `test_locked_account_rejects_correct_password`:鎖定後送出正確密碼仍回傳 423
- [x] 2.5 [整合測試] 實作 `test_nonexistent_email_does_not_increment_counter`:不存在 email 失敗 10 次後 Cache 無計數 key,不觸發鎖定
- [x] 2.6 [整合測試] 實作 `test_successful_login_clears_failure_counter`:失敗 3 次後成功登入,再失敗 4 次仍回 401(計數從 0 重算)
- [x] 2.7 [整合測試] 實作 `test_email_case_normalization_counts_same_account`:大小寫變體 email 累計失敗到同一 key,第 5 次觸發鎖定
- [x] 2.8 [整合測試] 實作 `test_email_trim_normalization_counts_same_account`:前後空白 email 累計失敗到同一 key,第 5 次觸發鎖定
- [x] 2.9 [整合測試] 實作 `test_member_login_attempts_do_not_affect_provider_lockout_for_same_email`:僅存在 provider 帳號的 email,對 member login 失敗 4 次(不增加計數)+ 對 provider login 失敗 4 次(計數=4,未鎖定,仍可正常登入)。原情境因 `users.email` 全域 unique 無法同時擁有 member+provider 帳號,已改寫並同步更新 spec.md
- [x] 2.10 [整合測試] 實作 `test_account_can_login_after_lockout_entry_removed`:鎖定後 `Cache::forget()` 移除 entry,帳號恢復可登入(回傳 200)
- [x] 2.11 [整合測試] 實作 `test_locked_until_comes_from_companion_cache_key`423 response 的 `locked_until` 等於 `login_expires_at:member:{email}` cache key 的值
- [x] 2.12 [整合測試] 實作 `test_repeated_failures_do_not_extend_lockout_window`:對應 spec.md「Fixed Window — 失敗不延長 TTL」情境(spec 已定義但原 tasks 未列出,此處補上)。第 1 次失敗寫入 `login_expires_at` 後,第 2~4 次失敗不應更動該值(驗證 `recordLoginFailure` 只在計數 key 不存在時才寫入 companion key,不隨後續失敗重設視窗)
## 3. AuthOAuthTest — P2 OAuth State 驗證
- [x] 3.1 [整合測試] 建立 `tests/Feature/AuthOAuthTest.php`,加入 `RefreshDatabase` trait;引入 `Laravel\Socialite\Facades\Socialite`
- [x] 3.2 [整合測試] 實作 `test_oauth_callback_without_state_redirects_to_error`:不帶 state 參數、session 無 `oauth_state`,斷言 redirect 含 `error=oauth_failed`,且 Socialite `user()` 不被呼叫(使用 `shouldNotReceive`
- [x] 3.3 [整合測試] 實作 `test_oauth_callback_with_wrong_state_redirects_to_error`session 設 `oauth_state=correct`,帶 `state=wrong`,斷言 redirect 含 `error=oauth_failed`,且 Socialite `user()` 不被呼叫
- [x] 3.4 [整合測試] 實作 `test_oauth_callback_with_correct_state_completes_login`session 設 `oauth_state=abc`,帶 `state=abc`Socialite mock 回傳假 Google user,斷言 redirect URL 含 `#token=`
- [x] 3.5 [整合測試] 實作 `test_oauth_state_is_consumed_after_successful_callback`:正確 state 完成一次登入後,再以相同 state 呼叫 callback,斷言第二次 redirect 含 `error=oauth_failed`state 一次性消耗)
## 4. 驗收確認
- [x] 4.1 [整合測試] 執行 `php artisan test --filter AuthLoginTest` 全數通過,無 skip19 passed / 60 assertions
- [x] 4.2 [整合測試] 執行 `php artisan test --filter AuthLockoutTest` 全數通過,無 skip11 passed / 44 assertions
- [x] 4.3 [整合測試] 執行 `php artisan test --filter AuthOAuthTest` 全數通過,無 skip4 passed / 12 assertions
- [x] 4.4 [整合測試] 在容器內執行 `docker exec cfdive-app php artisan test`host PHP 缺 GD 擴充套件,須在容器內跑才能涵蓋 `CourseImageTest`),結果:**98 passed / 256 assertions,全數通過,無回歸**。本次新增的 Auth 測試均已通過:AuthLoginTest 19、AuthLockoutTest 11、AuthOAuthTest 4(合計 34OAuth 部分已於 4.3 單獨驗證)
> **過程中排除的兩類既存失敗(均已解決,非本次改動引入)**
> - `CourseImageTest`11 個):原本在 host PHP 跑會因缺 GD 擴充套件報 `LogicException: GD extension is not installed.`;確認專案 `Dockerfile` 已內建 GD,改用 `docker exec cfdive-app php artisan test` 在容器內執行即全數通過 —— 純屬執行環境差異,非程式或測試邏輯問題,往後請統一在容器內跑測試
> - `AuthRateLimitTest`2 個):`member/provider login exceeds limit returns 429` 原本預期 429 實得 401。根因是過期測試——commit `2a0e9255`(P2 帳號鎖定上線時)刻意將 member/provider 的 IP throttle 從 `5,1` 調整為 `10,1`(避免 IP throttle 搶在帳號鎖定前觸發),但測試的迴圈次數與註解仍停留在舊版 `5,1`。已同步修正測試的迴圈次數(5→10)與區塊註解,使其對齊現行 `throttle:10,1` 設定,修正後全數通過
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-11
@@ -0,0 +1,35 @@
# Design — 2026-06-11 稽核修補
## D1admin register 封鎖方式——artisan command 而非 middleware 防護
候選方案:
- (a) 移除公開路由,改 `php artisan app:create-admin`(採用)
- (b) 端點移入 `auth:sanctum + admin` middleware group(僅既有 admin 可開新 admin
選 (a) 的理由:(b) 有「第一位管理員」雞生蛋問題,且保留 HTTP 入口就保留攻擊面;管理員建立頻率極低,不值得為其維護一個網路端點。command 密碼門檻設 min:8(高於一般使用者 min:6),因管理權限影響全平台。Seeder 直接以 Model 建立 admin,不受影響。
## D2rate-limit 漂移以實作(10/min)為基準
測試已在 commit 0dabc4e 有意配合實作改為 10 次,且 P2 帳號鎖定上線後已涵蓋暴力破解防護(per-email 計數),IP-based throttle 的角色退為防濫用,放寬至 10/min 可容納共享 IP(公司/學校 NAT)場景。故更新規格而非改回實作。Admin 維持 3/min 不變。
## D3is_verified 最小語意——列表過濾而非禁止上架
候選方案:
- (a) 未驗證教練課程從公開 index/show 排除(採用)
- (b) 未驗證教練 `store()` 回 403(禁止建立課程)
選 (a) 的理由:對教練的破壞較小(仍可登入、預先準備課程內容,通過驗證即曝光),可逆性高;(b) 會讓新教練在審核期間完全無事可做。實作為 `DivingOffer::scopeVisibleToPublic`(單一來源,index/show 共用):`provider_id` 為 null(平台自有資料)不受限,否則要求 `provider.providerProfile.is_verified = true`(無 profile 視同未驗證)。
快取一致性:公開列表有 180 秒 Redis tag 快取,`toggleProviderVerified` 後必須 `Cache::tags(['diving_offers'])->flush()`,否則切換最長延遲 3 分鐘生效。測試以「先打列表進快取 → toggle → 再打列表」驗證立即生效。
## D4:預約測試的保護目標(Rule 9——測試編碼 WHY)
- **狀態機**:終態(completed/rejected/expired/cancelled)不可再轉移——保護名額帳務與評價資格(只有 completed 能評價)
- **防超賣**:pending 不佔名額是刻意設計(教練未承諾前不鎖位),防線在 confirm 時 `lockForUpdate` 二次驗證;不變式為「confirmed 總人數 ≤ 容量」
- **Scheduler**:48h 過期與日期完成的邊界——過早 expire 砍掉教練來得及確認的單、過早 complete 讓未上課預約取得評價資格
- **聊天授權**HTTP 端點與 presence channel 雙防線都必須擋非參與方與非 confirmed 狀態
- **Admin 邊界**:非 admin 一律 403Admin 也不可繞過狀態機(rejected 不可 complete
## D5:分支基底
`fix/audit-remediation` 基底為 `test/auth-tests-coverage`(而非 master),因 PR #27 的 auth 測試實際尚未併入 master,而本次修補需在其測試之上修改 `AuthLoginTest`。merge 本 change 的 PR #28 會一併帶入 PR #27 內容。
@@ -0,0 +1,40 @@
## Why
2026-06-11 規格與實作稽核(`docs/analysis/2026-06-11-spec-implementation-audit.md`)發現四項問題:
1. **P0**`POST /api/admin/register` 完全公開且無任何防護,任何人一個 HTTP 請求即可註冊管理員帳號,使 P0~P2 認證安全強化形同虛設。該端點不在任何規格內(規格外實作)。
2. **P1**`provider_profiles.is_verified` 從未被業務邏輯強制執行——教練免審核即可上架課程、被公開曝光,Admin 的驗證開關是純展示功能,且無任何規格定義其語意。
3. **P1**`login-rate-limiting` 規格寫 5/min,實作與測試為 `throttle:10,1`(規格漂移,測試已在 commit 0dabc4e 改為配合實作但規格未同步)。
4. **P2**:預約狀態機、防超賣、Scheduler 自動轉移——平台核心業務——零測試覆蓋。
> 註:本 change 為事後補歸檔。稽核報告實質扮演 proposal 角色,實作先於本文件完成(branch `fix/audit-remediation`PR #28)。
## What Changes
- **移除** `POST /api/admin/register` 路由、`AuthController::registerAdmin`、對應 Swagger 文件
- **新增** `php artisan app:create-admin` command(密碼門檻 min:8),管理員帳號僅限主機端建立
- **新增** `DivingOffer::visibleToPublic` scope:公開 index/show 排除未驗證教練的課程(`provider_id` null 不受限);`toggle-verified` 後 flush `diving_offers` 快取
- **同步** `login-rate-limiting` 規格門檻至實作值 10/min
- **新增測試**`AdminAccountCreationTest`4)、`DivingOfferVisibilityTest`7)、`BookingLifecycleTest`17)、`BookingOversellTest`3)、`BookingSchedulerTest`6)、`BookingChatAuthTest`8)、`AdminEndpointAuthTest`5
- **移除** `AuthLoginTest` 中測已刪端點的 2 個 admin register 測試
- **規格清理**:補 `auth-test-coverage` Purpose、修正 `member-portal-ui` repo 描述
## Capabilities
### New Capabilities
- `provider-verification`:定義 `is_verified` 的最小業務語意——未驗證教練的課程不對公開端點曝光、切換立即生效、教練自有管理端點不受限
### Modified Capabilities
- `admin-auth`:新增「管理員帳號建立途徑」requirement(公開註冊端點關閉、僅限 `app:create-admin`);補「管理員查詢指定用戶資料」端點規格
- `login-rate-limiting`Member/Provider 門檻 5/min → 10/min(與實作一致,帳號鎖定已涵蓋暴力破解防護)
- `auth-test-coverage`admin register 場景改寫為「公開註冊端點保持關閉」;補歸檔遺留的 TBD Purpose
- `member-portal-ui`:前端位置由「獨立 repo」修正為本 repo `frontend/` 目錄
## Impact
- **行為變更**:未驗證教練(含 DemoSeeder 中 1 位 `is_verified=false` 的展示教練)課程從公開列表消失、詳情回 404;依賴 `POST /api/admin/register` 的外部工具需改用 `app:create-admin`
- **影響檔案**`routes/api.php``AuthController``AuthApiDoc``DivingOffer``DivingOfferController``AdminUserController`、新增 `app/Console/Commands/CreateAdminUser.php``tests/Feature/`(新增 7 檔)
- **已知限制**(留待完整教練審核流程):`/diving-offers/{id}/schedules``/diving-offers/{id}/reviews` 與預約建立流程未套用相同過濾
- 驗證:容器內 `php artisan test` 146 passed / 378 assertions
@@ -0,0 +1,29 @@
## 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 提供 `GET /api/admin/check-member/{id}``GET /api/admin/check-provider/{id}`(需 Bearer tokenrole=admin),依角色查詢指定用戶的基本資料與對應 profile。
#### Scenario: 查詢存在的用戶
- **WHEN** 管理員以有效 id 查詢對應角色的用戶
- **THEN** 回傳 HTTP 200 與該用戶資料
#### Scenario: id 不存在或角色不符
- **WHEN** 查詢的 id 不存在,或該用戶角色與端點不符(如以 check-member 查 provider
- **THEN** 回傳 HTTP 404
@@ -0,0 +1,14 @@
## MODIFIED Requirements
### Requirement: Auth 登入/註冊/登出測試覆蓋(三角色)
(僅列出本次變更的 scenario;其餘 scenario 不變)
原「Admin 註冊成功」與「Admin 重複 Email 註冊失敗」兩個 scenario 移除(端點已刪),改為:
#### Scenario: Admin 公開註冊端點保持關閉
- **WHEN** 任何人送出 `POST /api/admin/register`(該端點已於 2026-06-11 因 P0 漏洞移除,見 admin-auth 規格「管理員帳號建立途徑」)
- **THEN** 回傳 HTTP 404,且不建立任何帳號;帳號建立改由 `app:create-admin` command 覆蓋測試
另補主規格歸檔時遺留的 TBD Purpose
> 驗證三角色(member / provider / admin)認證流程的測試覆蓋契約:登入/註冊/登出、帳號鎖定(P2)、OAuth state 驗證、token refresh 與登入頻率限制。此規格定義測試套件必須覆蓋的場景,任何認證行為變更時,對應測試必須同步失敗以攔截回歸。
@@ -0,0 +1,12 @@
## MODIFIED Requirements
### Requirement: 登入頻率限制
後端 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` 送出 10 次以內的請求
- **THEN** 請求正常處理,回傳對應的登入結果(200 成功或 401 失敗)
#### Scenario: Member / Provider 超過頻率限制
- **WHEN** 同一 IP 在 1 分鐘內送出第 11 次 member 或 provider 登入請求
- **THEN** 回傳 HTTP 429,並帶有 `Retry-After` header 指示等待時間
@@ -0,0 +1,12 @@
## MODIFIED Requirements
### Requirement: 專案基礎建設
前端 SHALL 建立於本 repo 的 `frontend/` 目錄(原規劃獨立 repo,後併入主 repo 以簡化版控與部署),使用 Vue 3 + Vite + Tailwind CSS + Vue Router 4 + Pinia + Axios,並設定 `.env` 指定後端 API base URL。
#### Scenario: 開發環境啟動
- **WHEN** 開發者執行 `npm run dev`
- **THEN** 應用在 `http://localhost:5173` 啟動,無編譯錯誤
#### Scenario: API base URL 設定
- **WHEN** `.env` 中設定 `VITE_API_URL=http://localhost:80`
- **THEN** 所有 Axios 請求以此為 base URL
@@ -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。

Some files were not shown because too many files have changed in this diff Show More