Compare commits

...

41 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
88 changed files with 4506 additions and 349 deletions
+22 -5
View File
@@ -15,8 +15,7 @@ APP_MAINTENANCE_STORE=database
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_CHANNEL=stderr
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
@@ -28,15 +27,15 @@ DB_CONNECTION=sqlite
# DB_PASSWORD=
# MYSQL_ROOT_PASSWORD=
SESSION_DRIVER=database
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
FILESYSTEM_DISK=local # 雲端應改為 s3(local 在容器重啟後會遺失上傳檔案)
QUEUE_CONNECTION=redis # 雲端建議值;database 效能較差且不利擴容
CACHE_STORE=redis
CACHE_PREFIX=
@@ -67,3 +66,21 @@ VITE_APP_NAME="${APP_NAME}"
SENTRY_LARAVEL_DSN=
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 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
run: |
cd /root/myproject/CFDivePlatform
+10 -6
View File
@@ -8,18 +8,22 @@ on:
jobs:
test:
runs-on: ubuntu-latest
runs-on: self-hosted
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: mbstring, dom, fileinfo, gd, pdo_sqlite, sqlite3
coverage: none
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
+1
View File
@@ -27,3 +27,4 @@ yarn-error.log
/frontend/dist
/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 \
default-mysql-client \
netcat-openbsd \
grep \
cron
grep
# 清理 apt 快取以減小鏡像大小
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
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 指令之前執行
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+31 -25
View File
@@ -8,6 +8,7 @@
**會員(Member**
- 註冊 / 登入(Email + Google OAuth
- Token 自動續期(401 refresh-then-retrysessionStorage 儲存)
- 瀏覽、搜尋、篩選潛水課程
- 查看課程時段並送出預約
- 與教練即時訊息(文字 + 圖片,含已讀回執)
@@ -15,14 +16,16 @@
- 站內通知(Bell Icon 即時更新 + 瀏覽器推播)
**教練(Provider**
- 課程 CRUD(含封面 + 相簿圖片上傳)
- 課程 CRUD(含封面 + 相簿圖片上傳,伺服器端壓縮
- 課程時段管理
- 預約管理(確認 / 拒絕 / 完成 / 取消)
- 與學員即時訊息
- 證照上傳與教練資格送審
**管理員(Admin**
- 平台統計數據(會員數、教練數、課程數)
- 會員與教練帳號管理(啟用 / 停用 / 審核
- 會員與教練帳號管理(啟用 / 停用)
- 教練資格審核(送審 / 通過 / 駁回 / 撤銷)
- 課程、預約、評價管理
---
@@ -36,23 +39,29 @@
| 資料庫 | MySQL 8.0 |
| 快取 | Redis |
| 即時通訊 | Laravel ReverbWebSocket`wss://ws.hank-space.com`|
| 認證 | Laravel Sanctum + Google OAuth |
| 認證 | Laravel Sanctum + Google OAuth + Token Refresh |
| 容器 | Docker / Docker Compose |
| API 文件 | Swagger UIl5-swagger|
| 錯誤監控 | Sentry |
| CI/CD | Gitea Actions(自動部署至 VPS|
| 測試 | PHPUnit / Laravel Feature & Unit Tests |
---
## API 文件
共 73 個端點,涵蓋
- 認證(Email + Google OAuth
Swagger UI(由 l5-swagger 生成)涵蓋所有端點,包含
- 認證(Email + Google OAuth、Token 刷新
- 公開課程查詢
- 會員預約 / 訊息 / 評價 / 通知
- 教練課程 / 時段 / 預約管理
- 會員預約 / 即時訊息 / 評價 / 通知
- 教練課程 / 時段 / 預約管理 / 資格驗證申請(證照送審)
- 管理員後台
| 環境 | Swagger UI |
|------|-----------|
| 本地 | http://localhost:8080/api/documentation |
| 生產 | https://api.hank-space.com/api/documentation |
---
## 健康檢查
@@ -67,25 +76,22 @@ GET /health
## 本地開發
**啟動**
**測試用帳號**
```bash
docker compose up -d
```
| 角色 | 帳號 | 密碼 | 用途 |
|------|------|------|------|
| 會員 | Guest@cfdive.com | guestpassword | 體驗課程瀏覽、預約、會員預約紀錄 |
| 教練 | Guest_Coach@cfdive.com | coachpassword | 體驗課程管理、時段管理、預約管理 |
| 服務 | URL |
|------|-----|
| API / 前端 | http://localhost:8080 |
| phpMyAdmin | http://localhost:8081 |
| Mailpit | http://localhost:8025 |
| Reverb WebSocket | ws://localhost:8085 |
**教練頁入口**
**環境設定**
目前首頁尚未提供明顯導引到教練後台的入口。若要體驗教練功能,請直接開啟 `/coach/login`,使用上方教練試用帳號登入後會進入 `/coach/dashboard`
複製 `.env.example``.env`,填入以下必要值:
```env
REVERB_APP_KEY= # 32 字元隨機字串
REVERB_APP_SECRET= # 32 字元隨機字串
SENTRY_LARAVEL_DSN= # Sentry DSN(留空則停用)
```
| 服務 | 本地 | 生產 |
|------|------|------|
| 前端 | 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 |
+68 -13
View File
@@ -283,30 +283,85 @@ class AdminApiDoc
}
/**
* 切換教練審核狀態
* 教練審核佇列
*
* @OA\Put(
* path="/admin/providers/{id}/toggle-verified",
* summary="切換教練審核狀態",
* description="通過或撤銷教練審核,回傳新的 is_verified 狀態",
* operationId="toggleProviderVerified",
* @OA\Get(
* path="/admin/verifications",
* summary="教練審核佇列",
* description="查詢教練驗證申請(預設僅 pending;status=all 可查全部),含證照圖片 URL",
* operationId="adminVerificationIndex",
* tags={"Admin 教練管理"},
* 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(
* response=200,
* description="切換成功",
* description="查詢成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="教練已通過審核"),
* @OA\Property(property="is_verified", type="boolean", example=true)
* @OA\Property(property="data", type="array", @OA\Items(
* @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=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* @OA\Response(response=403, description="非 admin 角色", @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()
{
}
+100 -4
View File
@@ -12,7 +12,7 @@ use OpenApi\Annotations as OA;
* )
*
* @OA\Server(
* url="/api",
* url=L5_SWAGGER_CONST_HOST,
* description="API 伺服器"
* )
*
@@ -207,9 +207,41 @@ 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()
{
}
/**
* 會員登出
*
*
* @OA\Post(
* path="/member/logout",
* summary="會員登出",
@@ -580,9 +612,41 @@ 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()
{
}
/**
* 服務提供者登出
*
*
* @OA\Post(
* path="/provider/logout",
* summary="服務提供者登出",
@@ -906,9 +970,41 @@ 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()
{
}
/**
* 管理員登出
*
*
* @OA\Post(
* path="/admin/logout",
* summary="管理員登出",
+150
View File
@@ -17,6 +17,10 @@ use OpenApi\Annotations as OA;
* name="通知",
* description="站內通知管理(Member / Provider 共用)"
* )
* @OA\Tag(
* name="即時訊息",
* description="預約聊天室訊息(Member / Provider 共用,僅限 confirmed / completed 預約的參與方)"
* )
*/
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 共用)
// -----------------------------------------------------------------------
+141
View File
@@ -21,9 +21,150 @@ use OpenApi\Annotations as OA;
* name="教練預約",
* description="服務提供者的預約管理"
* )
* @OA\Tag(
* name="教練驗證",
* description="服務提供者的資格驗證申請(證照送審)"
* )
*/
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
// -----------------------------------------------------------------------
+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] ?? []);
}
}
@@ -5,7 +5,6 @@ namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class AdminUserController extends Controller
{
@@ -132,23 +131,5 @@ class AdminUserController extends Controller
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]);
}
public function toggleProviderVerified(int $id)
{
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();
// 驗證狀態影響公開課程列表的可見性,需立即讓快取失效
Cache::tags(['diving_offers'])->flush();
$msg = $profile->is_verified ? '教練已驗證' : '已取消驗證';
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_verified' => $profile->is_verified]]);
}
// toggleProviderVerified 已移除:驗證狀態變更一律走 AdminVerificationController 的審核狀態機
}
@@ -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());
}
}
}
@@ -48,9 +48,10 @@ class MemberBookingController extends Controller
$schedule = CourseSchedule::with('divingOffer.provider.providerProfile')->findOrFail($data['schedule_id']);
// Layer 1:快速失敗
// 可見性繞過防護:課程屬未驗證教練時不可建立新預約(既有預約不受影響,見 provider-verification 規格)
// 可見性繞過防護:課程教練未通過審核時不可建立新預約(既有預約不受影響,見 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) {
@@ -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;
}
}
+2 -2
View File
@@ -55,14 +55,14 @@ class DivingOffer extends Model
}
/**
* 公開端點可見性:未驗證教練的課程不對外曝光。
* 公開端點可見性:未通過審核(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('is_verified', true));
->orWhereHas('provider.providerProfile', fn (Builder $profile) => $profile->where('verification_status', \App\Enums\VerificationStatus::Approved->value));
});
}
+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;
use App\Enums\VerificationStatus;
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="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="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="website", type="string", example="https://www.bluedive.com", description="官方網站"),
* @OA\Property(property="social_media", type="string", example="https://www.facebook.com/bluedive", description="社群媒體連結"),
@@ -55,7 +58,8 @@ class ProviderProfile extends Model
'certifications',
'facilities',
'business_hours',
'is_verified',
'verification_status',
'rejection_reason',
'rating',
'website',
'social_media',
@@ -64,6 +68,21 @@ class ProviderProfile extends Model
'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);
}
/**
* 教練驗證證照圖片(送審用)
*/
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');
}
}
+1
View File
@@ -13,6 +13,7 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(at: ['*']);
$middleware->alias([
'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
+1 -1
View File
@@ -5,7 +5,7 @@ return [
'documentations' => [
'default' => [
'api' => [
'title' => 'L5 Swagger UI',
'title' => 'CFDive Platform API',
],
'routes' => [
@@ -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認證課程,夜潛,水下攝影',
'certifications' => 'PADI 五星級潛水中心,PADI Course Director',
'facilities' => '空氣填充站,氧氣填充站,裝備租借,沖洗區,更衣室,停車場',
'business_hours' => '每日 07:3018:00', 'is_verified' => true,
'business_hours' => '每日 07:3018:00', 'verification_status' => 'approved',
],
[
'email' => 'greendive@cfdive.com', 'name' => '陳美玲',
@@ -77,7 +77,7 @@ class DemoSeeder extends Seeder
'services' => '體驗潛水,OW認證課程,AOW認證課程,水下生態導覽,浮潛',
'certifications' => 'PADI 潛水中心,SSI 認證教練',
'facilities' => '空氣填充站,裝備租借,防寒衣洗滌區,水下攝影記錄',
'business_hours' => '每日 07:0017:30', 'is_verified' => true,
'business_hours' => '每日 07:0017:30', 'verification_status' => 'approved',
],
[
'email' => 'islet@cfdive.com', 'name' => '張大偉',
@@ -89,7 +89,7 @@ class DemoSeeder extends Seeder
'services' => '浮潛,體驗潛水,OW認證課程,海龜觀察導覽',
'certifications' => 'PADI 授權潛水中心',
'facilities' => '裝備租借,沖洗區,更衣室,代訂民宿服務',
'business_hours' => '每日 08:0017:00', 'is_verified' => true,
'business_hours' => '每日 08:0017:00', 'verification_status' => 'approved',
],
[
'email' => 'northdive@cfdive.com', 'name' => '王建國',
@@ -101,7 +101,7 @@ class DemoSeeder extends Seeder
'services' => '體驗潛水,OW認證課程,進階課程,Tec潛水,洞穴入門',
'certifications' => 'PADI 課程總監,PADI TecRec 教練',
'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'],
'facilities' => $data['facilities'],
'business_hours' => $data['business_hours'],
'is_verified' => $data['is_verified'],
'verification_status' => $data['verification_status'],
'rating' => 0,
]);
$providers[] = $user;
+1 -1
View File
@@ -35,7 +35,7 @@ class DevelopmentSeeder extends Seeder
'description' => '專業 PADI 認證教練,10 年教學經驗',
'contact_phone' => '0912345678',
'contact_email' => 'coach@cfdive.com',
'is_verified' => true,
'verification_status' => 'approved',
]);
// Member
+24 -20
View File
@@ -38,11 +38,11 @@ services:
# 健康檢查配置
healthcheck:
test: ["CMD", "php", "-v"] # 檢查 PHP 版本確認服務正常
interval: 30s # 每30秒檢查一次
timeout: 10s # 超時時間10秒
retries: 3 # 重試3次
start_period: 40s # 容器啟動後40秒開始檢查
test: ["CMD-SHELL", "nc -z 127.0.0.1 9000 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
nginx:
image: nginx:alpine
@@ -109,20 +109,26 @@ services:
ports:
- "127.0.0.1:3306:3306" # 只綁 localhost,不對外
phpmyadmin:
image: phpmyadmin/phpmyadmin
container_name: cfdive-phpmyadmin
scheduler:
image: cfdive-platform
container_name: cfdive-scheduler
restart: unless-stopped
working_dir: /var/www/
command: php artisan schedule:work
volumes:
- ./:/var/www
environment:
PMA_HOST: db
PMA_PORT: 3306
PMA_USER: ${DB_USERNAME:-cfdiveuser}
PMA_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
- DB_CONNECTION=mysql
- DB_HOST=db
- DB_PORT=3306
- DB_DATABASE=${DB_DATABASE:-CFDivePlatform}
- DB_USERNAME=${DB_USERNAME:-cfdiveuser}
- DB_PASSWORD=${DB_PASSWORD}
networks:
- cfdive-network
depends_on:
db:
app:
condition: service_healthy
queue-worker:
@@ -146,12 +152,6 @@ services:
app:
condition: service_healthy
mailpit:
image: axllent/mailpit
container_name: cfdive-mailpit
restart: unless-stopped
networks:
- cfdive-network
redis:
image: redis:alpine
@@ -159,6 +159,8 @@ services:
restart: unless-stopped
networks:
- cfdive-network
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
@@ -205,3 +207,5 @@ networks:
volumes:
mysql-data:
driver: local
redis-data:
driver: local
-3
View File
@@ -59,8 +59,5 @@ fi
echo "✅ [背景] 初始化完成"
) &
# 啟動 cronLaravel Scheduler
service cron start || cron || true
echo "🚀 啟動 php-fpm..."
exec "$@"
+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
```
+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/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/verification" class="text-sm hover:text-gray-300 transition">驗證申請</RouterLink>
<RouterLink to="/coach/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink>
</div>
+1
View File
@@ -30,6 +30,7 @@ const routes = [
{ path: 'schedules', component: () => import('../views/coach/ScheduleManagerView.vue') },
{ path: 'bookings', component: () => import('../views/coach/BookingManagerView.vue') },
{ path: 'reviews', component: () => import('../views/coach/ReviewsView.vue') },
{ path: 'verification', component: () => import('../views/coach/VerificationView.vue') },
],
},
+97 -15
View File
@@ -2,9 +2,24 @@
import { ref, onMounted } from 'vue'
import adminApi from '../../api/adminAxios'
const providers = ref([])
const loading = ref(true)
const search = ref('')
const providers = ref([])
const loading = ref(true)
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() {
loading.value = true
@@ -12,12 +27,19 @@ async function fetchProviders() {
const params = {}
if (search.value) params.q = search.value
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 {
loading.value = false
}
}
function togglePendingFilter() {
pendingOnly.value = !pendingOnly.value
fetchProviders()
}
async function toggleActive(p) {
try {
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 || '操作失敗') }
}
async function toggleVerified(p) {
async function viewCertifications(p) {
try {
const res = await adminApi.put(`/admin/providers/${p.id}/toggle-verified`)
if (p.provider_profile) p.provider_profile.is_verified = res.data.data.is_verified
const res = await adminApi.get('/admin/verifications', { params: { status: 'all' } })
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 || '操作失敗') }
}
@@ -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" />
<button @click="fetchProviders"
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 v-if="loading" class="text-center text-slate-400 py-20">載入中...</div>
@@ -67,9 +114,8 @@ onMounted(fetchProviders)
</td>
<td class="px-6 py-4 text-slate-500">{{ p.provider_profile?.business_name || '-' }}</td>
<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'"
class="text-xs px-2 py-1 rounded-full font-medium">
{{ p.provider_profile?.is_verified ? '已驗證' : '未驗證' }}
<span :class="statusMeta(p).cls" class="text-xs px-2 py-1 rounded-full font-medium">
{{ statusMeta(p).label }}
</span>
</td>
<td class="px-6 py-4 text-center">
@@ -79,11 +125,18 @@ onMounted(fetchProviders)
</span>
</td>
<td class="px-6 py-4 text-center">
<div class="flex justify-center gap-2">
<button @click="toggleVerified(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 hover:opacity-80 transition">
{{ p.provider_profile?.is_verified ? '取消驗證' : '驗證' }}
<div class="flex justify-center gap-2 flex-wrap">
<button @click="viewCertifications(p)"
class="text-xs px-3 py-1 rounded-lg bg-slate-50 text-slate-600 hover:opacity-80 transition">
證照
</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 @click="toggleActive(p)"
:class="p.is_active ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-700'"
@@ -99,5 +152,34 @@ onMounted(fetchProviders)
</tbody>
</table>
</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>
</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-16
@@ -0,0 +1,56 @@
## Context
現有 187 個 Feature test 分佈在認證、狀態機邊界、聊天授權等安全層,核心業務端點(Provider 課程管理、時段管理、Admin 使用者操作、通知觸發、預約列表)完全未覆蓋。缺口已於 2026-06-11 稽核報告中識別(通知系統「僅 Scheduler 路徑覆蓋」),此 change 補齊這五個區塊。
**現有 Factory 狀況**`database/factories/` 只有 `UserFactory``DivingOffer``CourseSchedule``Booking` 均無 factory——現有測試一律使用 `Model::create([...])` 直接建立,新測試沿用此慣例,不新增 factory(避免引入 app 行為)。
**認證方式**:現有 Feature test 全部使用 `$this->actingAs($user)` + `getJson/postJson` 等 HTTP helpers,不手動建立 token。新測試維持相同模式。
## Goals / Non-Goals
**Goals:**
- 5 個新 Feature test 類別:`ProviderOfferCrudTest``ProviderScheduleCrudTest``AdminUserManagementTest``NotificationTriggerTest``BookingListTest`
- 涵蓋正常流程(happy path)+ 所有權邊界(403+ 輸入驗證(422)
- 通知測試補齊直接事件路徑(建立/確認/拒絕/取消),不只驗證 Scheduler 完課路徑
**Non-Goals:**
- 不修改任何 app 程式碼
- 不補 Unit test(已於上一個 change 完成)
- 不測試 UI / 前端行為
- 不測試通知 Email 實際發送(`notification-email` 規格另有覆蓋)
## Decisions
### D1:延續現有 Feature test 架構,不引入 Service layer mock
現有測試全部使用 `RefreshDatabase` + 真實 DB,未 mock 任何 repository/service。維持一致性優先於執行速度——187 案例目前 42 秒,新增 ~50 案例後預估 55 秒以內,可接受。
**替代方案**mock `Cache::tags()``Notification`。決議:`Notification::fake()` 繼續使用(現有慣例),`Cache::tags()` 讓它真實運行(Redis 在 Docker 中可用)。
### D2:通知測試使用 `Notification::fake()` + `assertSentTo()`
現有 `BookingSchedulerTest` 已驗證 Scheduler 完課觸發通知。`NotificationTriggerTest` 補齊 controller 直接觸發的路徑(`store``confirm``reject``member cancel``provider cancel`),同樣 fake 後斷言 `Notification::assertSentTo()`,不依賴 queue worker。
### D3:預約列表測試不涵蓋分頁(暫緩 O3.2)
`MemberBookingController::index()` 目前為 `->get()` 全量回傳(O3.2 未完成),測試僅斷言回傳正確的資料集合(`data` 陣列包含/不包含正確的 booking)。分頁斷言待 O3.2 實作後加入。
### D4:所有權邊界是必測案例,非選測
Provider CRUD 的「他人不可操作」403 路徑在現有 `BookingLifecycleTest` 已有先例,這裡對 Offer 和 Schedule 同樣必須測。Admin 操作已有 `AdminEndpointAuthTest` 覆蓋角色層的 401/403`AdminUserManagementTest` 聚焦資料正確性與操作副作用(is_active 切換、is_verified 切換 + 快取清除)。
## Risks / Trade-offs
- **`Cache::tags()` 與 driver 相容性**`phpunit.xml` 設定 `CACHE_STORE=array`。Laravel 11 的 `array` store 繼承 `TaggableStore`,因此 `Cache::tags(['diving_offers'])->flush()` 在測試環境可正常執行(`DivingOfferVisibilityTest::test_toggle_verified_takes_effect_immediately_despite_cache` 已驗證)。真正的風險是:若未來 CI 或本機改用 `file``database` 等不支援 tags 的 driver,所有呼叫 `Cache::tags()` 的 controller 測試都會拋出 `BadMethodCallException`。→ 緩解:在 tasks 加上驗收項目,確認 `phpunit.xml``CACHE_STORE``array``redis`,並加入 README 說明。
- **Admin toggle-verified 的快取副作用驗證方式**`DivingOfferVisibilityTest` 已有端對端驗證(toggle → 公開列表立即消失)。`AdminUserManagementTest` 不重複這個斷言,只驗證 `is_verified` 欄位變更與 HTTP 200 即可,避免測試重複。
- **Notification::fake() 無法驗證 mail 實際送出**:這是刻意的邊界——只驗證通知物件被派發至正確收件者,不驗證 email 模板內容(屬於 `notification-email` 規格的範疇)。
## 各測試類別案例清單
| 測試類別 | 案例 | 涵蓋邊界 |
|----------|------|---------|
| `ProviderOfferCrudTest` | store 成功、缺必填 422、update 成功、update 他人 403、destroy 成功、destroy 他人 403、未認證 401 | happy path + ownership + auth |
| `ProviderScheduleCrudTest` | store 成功、store 他人課程 403、過去日期 422、update max_participants 成功、update 低於 current 422、update 他人 403、destroy 同時取消 bookings | happy path + 容量不變式 + ownership |
| `AdminUserManagementTest` | 列出 members 只含 member role、列出 providers 只含 provider role、toggle-active 改變 is_active、toggle-verified 改變 is_verified、非 admin 403 | 資料正確性 + 角色隔離 |
| `NotificationTriggerTest` | BookingCreated→provider、Confirmed→member、Rejected→member、CancelledByMember→provider、CancelledByProvider→member | 5 條直接觸發路徑,收件者各不同 |
| `BookingListTest` | `GET /api/member/bookings`MemberBookingController::index)、`GET /api/provider/bookings`ProviderBookingController::index)、`GET /api/admin/bookings`AdminBookingController::index)——各建兩方資料斷言隔離、admin 全看、未認證 401 | 路由已確認存在;角色資料隔離邊界 |
@@ -0,0 +1,26 @@
## Why
稽核報告(2026-06-11)標註通知系統「僅 Scheduler 路徑覆蓋」,且 Provider Offer/Schedule CRUD、Admin 使用者管理、預約列表端點至今零 Feature test。187 個現有案例全集中在認證與狀態機邊界,核心業務流程端點的回歸一旦發生,測試套件無法攔截。
## What Changes
- 新增 `ProviderOfferCrudTest`:教練課程的建立、更新、刪除,含所有權驗證(他人不可操作)
- 新增 `ProviderScheduleCrudTest`:課程時段的建立、更新、刪除,含容量驗證與所有權邊界
- 新增 `AdminUserManagementTest`:Admin 查詢使用者列表、切換教練驗證狀態(toggle-verified
- 新增 `NotificationTriggerTest`:預約事件直接觸發通知(建立、確認、拒絕、取消、完成);補足現有 Scheduler 路徑以外的觸發路徑
- 新增 `BookingListTest`Member / Provider / Admin 各自的預約列表端點(分頁與角色過濾)
## Capabilities
### New Capabilities
- `feature-test-coverage`:定義核心業務流程端點的測試覆蓋契約,涵蓋 Provider Offer CRUD、Schedule CRUD、Admin 使用者管理、通知觸發路徑、預約列表端點;對應 auth-test-coverage 在認證層的角色
### Modified Capabilities
<!-- 現有行為規格無異動,僅補測試覆蓋,無須 delta spec -->
## Impact
- 新增 Feature test 檔案:`tests/Feature/` 下 5 個新測試類別(約 40~55 案例)
- 不修改任何 app 程式碼
- 不影響現有 API 行為或資料結構
- 現有 187 案例零 regressionUnit test 已驗證)
@@ -0,0 +1,146 @@
# feature-test-coverage Specification
## Purpose
定義核心業務流程端點的測試覆蓋契約:Provider 課程 CRUD、時段管理、Admin 使用者操作、通知直接觸發路徑、預約列表端點。補齊 2026-06-11 稽核報告識別的 Feature test 缺口,確保這些端點的回歸能被測試套件即時攔截。
## ADDED Requirements
---
### Requirement: Provider 課程 CRUD 測試覆蓋
測試套件 SHALL 驗證 `POST /api/provider/offers``PUT /api/provider/offers/{id}``DELETE /api/provider/offers/{id}` 的正常流程與所有權邊界。
#### Scenario: Provider 建立課程成功
- **WHEN** 已認證的 Provider 送出有效的 title / location / price / region 至 `POST /api/provider/offers`
- **THEN** 回傳 HTTP 201body 包含 `{ status: true, data: { id, title, provider_id } }`DB 存在對應記錄且 `provider_id` 為當前 Provider
#### Scenario: 建立課程缺少必填欄位回傳 422
- **WHEN** Provider 送出缺少 `title``price` 的請求至 `POST /api/provider/offers`
- **THEN** 回傳 HTTP 422,body 包含對應欄位的驗證錯誤
#### Scenario: Provider 更新自己的課程
- **WHEN** 已認證的 Provider 送出有效更新欄位至 `PUT /api/provider/offers/{id}`(該課程屬於此 Provider
- **THEN** 回傳 HTTP 200,body 包含更新後的課程資料,DB 記錄已變更
#### Scenario: Provider 不可更新他人課程
- **WHEN** Provider A 嘗試 `PUT /api/provider/offers/{id}`,但該課程屬於 Provider B
- **THEN** 回傳 HTTP 403body 包含 `{ status: false }`
#### Scenario: Provider 刪除自己的課程
- **WHEN** 已認證的 Provider 送出 `DELETE /api/provider/offers/{id}`(該課程屬於此 Provider
- **THEN** 回傳 HTTP 200body 包含 `{ status: true }`DB 記錄已不存在
#### Scenario: Provider 不可刪除他人課程
- **WHEN** Provider A 嘗試 `DELETE /api/provider/offers/{id}`,但該課程屬於 Provider B
- **THEN** 回傳 HTTP 403
#### Scenario: 未認證請求被拒絕
- **WHEN** 未帶任何 token 送出 `POST /api/provider/offers`
- **THEN** 回傳 HTTP 401
---
### Requirement: Provider 時段管理測試覆蓋
測試套件 SHALL 驗證 `POST /api/provider/schedules``PUT /api/provider/schedules/{id}``DELETE /api/provider/schedules/{id}` 的正常流程、所有權邊界與容量驗證。
#### Scenario: Provider 建立時段成功
- **WHEN** Provider 送出有效的 diving_offer_id / scheduled_date(未來日期)/ start_time / max_participants 至 `POST /api/provider/schedules`
- **THEN** 回傳 HTTP 201body 包含 `{ status: true, data: { id, scheduled_date, status: "open" } }`
#### Scenario: Provider 不可為他人課程建立時段
- **WHEN** Provider A 送出 Provider B 課程的 diving_offer_id 至 `POST /api/provider/schedules`
- **THEN** 回傳 HTTP 403
#### Scenario: 時段日期不可為過去
- **WHEN** Provider 送出 scheduled_date 為過去日期
- **THEN** 回傳 HTTP 422
#### Scenario: Provider 更新時段人數上限
- **WHEN** Provider 送出新的 max_participants 至 `PUT /api/provider/schedules/{id}`(新值 ≥ 目前 current_participants
- **THEN** 回傳 HTTP 200DB 記錄已更新
#### Scenario: 人數上限不可低於已確認人數
- **WHEN** Provider 嘗試將 max_participants 設為低於時段 current_participants 的值
- **THEN** 回傳 HTTP 422,message 說明不可低於已確認人數
#### Scenario: Provider 不可更新他人時段
- **WHEN** Provider A 嘗試 `PUT /api/provider/schedules/{id}`,但該時段屬於 Provider B
- **THEN** 回傳 HTTP 403
#### Scenario: 刪除(取消)時段同時將進行中預約標記為 provider_cancelled
- **WHEN** Provider 對有 pending/confirmed 預約的時段送出 `DELETE /api/provider/schedules/{id}`
- **THEN** 回傳 HTTP 200;時段 status 改為 `cancelled`(記錄保留,不刪除);該時段的 pending/confirmed booking status 改為 `provider_cancelled`(記錄保留,不刪除);已是終態的 bookingcompleted/expired/已取消)不受影響
---
### Requirement: Admin 使用者管理測試覆蓋
測試套件 SHALL 驗證 Admin 查詢會員/教練列表、切換帳號啟用狀態、切換教練驗證狀態的正確行為。
#### Scenario: Admin 取得會員列表
- **WHEN** Admin 送出 `GET /api/admin/members`
- **THEN** 回傳 HTTP 200data 陣列只包含 role=member 的使用者,含 meta 分頁資訊
#### Scenario: Admin 取得教練列表
- **WHEN** Admin 送出 `GET /api/admin/providers`
- **THEN** 回傳 HTTP 200data 陣列只包含 role=provider 的使用者
#### Scenario: Admin 切換會員帳號啟用狀態
- **WHEN** Admin 送出 `POST /api/admin/members/{id}/toggle-active`(目標會員 is_active=true
- **THEN** 回傳 HTTP 200DB 記錄 is_active 由 true 變為 false
#### Scenario: Admin 切換教練驗證狀態並清除快取
- **WHEN** Admin 送出 `POST /api/admin/providers/{id}/toggle-verified`(目標教練 is_verified=false
- **THEN** 回傳 HTTP 200provider_profiles.is_verified 由 false 變為 truediving_offers 快取被清除(後續公開列表可見該教練課程)
#### Scenario: 非 Admin 角色被拒絕
- **WHEN** role=member 的使用者送出 `GET /api/admin/members`
- **THEN** 回傳 HTTP 403
---
### Requirement: 通知直接觸發路徑測試覆蓋
測試套件 SHALL 驗證預約 controller 操作直接觸發的通知事件,補足 Scheduler 路徑以外的覆蓋缺口。收件者依實際 controller 實作:新預約與 member 取消通知教練,其餘通知學員。
#### Scenario: 建立預約觸發 BookingCreatedNotification 至教練
- **WHEN** Member 成功建立預約(`POST /api/member/bookings`
- **THEN** `BookingCreatedNotification` 被派發至該預約所屬的 **Provider**`MemberBookingController::store` 呼叫 `$provider->notify(...)`
#### Scenario: Provider 確認預約觸發 BookingConfirmedNotification 至學員
- **WHEN** Provider 對 pending 預約呼叫 `PUT /api/provider/bookings/{id}/confirm`
- **THEN** `BookingConfirmedNotification` 被派發至該預約的 **Member**
#### Scenario: Provider 拒絕預約觸發 BookingRejectedNotification 至學員
- **WHEN** Provider 對 pending 預約呼叫 `PUT /api/provider/bookings/{id}/reject`
- **THEN** `BookingRejectedNotification` 被派發至該預約的 **Member**
#### Scenario: Member 取消預約觸發 BookingCancelledNotification 至教練
- **WHEN** Member 對 pending 預約呼叫 `DELETE /api/member/bookings/{id}`
- **THEN** `BookingCancelledNotification` 被派發至該預約所屬的 **Provider**`cancelledBy: 'member'`
#### Scenario: Provider 取消預約觸發 BookingCancelledNotification 至學員
- **WHEN** Provider 對 confirmed 預約呼叫 `PUT /api/provider/bookings/{id}/cancel`
- **THEN** `BookingCancelledNotification` 被派發至該預約的 **Member**`cancelledBy: 'provider'`
---
### Requirement: 預約列表端點測試覆蓋
測試套件 SHALL 驗證下列三個已存在路由的資料隔離行為:
- `GET /api/member/bookings`middleware: `auth:sanctum`,資料隔離在 controller `where member_id = auth()->id()`
- `GET /api/provider/bookings`middleware: `auth:sanctum`,資料隔離在 controller `whereHas schedule.provider_id`
- `GET /api/admin/bookings`middleware: `auth:sanctum` + `admin`EnsureAdmin 於 middleware 層強制 403
#### Scenario: Member 只能看到自己的預約
- **WHEN** Member A 呼叫 `GET /api/member/bookings`,系統中同時存在 Member A 與 Member B 各一筆預約
- **THEN** 回傳 data 陣列長度為 1,且 id 為 Member A 的 booking idMember B 的 booking id 不在 data 中
#### Scenario: Provider 只能看到屬於自己課程的預約
- **WHEN** Provider A 呼叫 `GET /api/provider/bookings`,系統中有屬於 Provider A 課程與 Provider B 課程各一筆預約
- **THEN** 回傳 data 中只包含 Provider A 課程的 booking idProvider B 課程的 booking id 不在 data 中
#### Scenario: Admin 可查看所有預約
- **WHEN** Admin 呼叫 `GET /api/admin/bookings`,系統中有 2 筆不同 Member 的預約
- **THEN** 回傳 data count ≥ 2,兩筆 booking id 均在 data 中
#### Scenario: 未認證請求被拒絕
- **WHEN** 未帶 token 呼叫 `GET /api/member/bookings`
- **THEN** 回傳 HTTP 401
@@ -0,0 +1,83 @@
## 0. 前置確認(實作前一次性)
- [x] 0.1 確認 `phpunit.xml``CACHE_STORE=array`Laravel 11 array store 支援 tags,若非 array/redis 須先修正再實作)
- [x] 0.2 確認現有認證模式:所有新測試使用 `$this->actingAs($user)` + `postJson/putJson`,不手動建立 Sanctum token
- [x] 0.3 確認 factory 狀況:`DivingOffer``CourseSchedule``Booking` 均無 factory,一律使用 `Model::create([...])` 直接建立,不新增 factory
## 1. Provider 課程 CRUD 測試
- [x] 1.1 [整合測試] 建立 `tests/Feature/ProviderOfferCrudTest.php`,加入 private helpers
- `makeProvider(): User`User::create + ProviderProfile::createis_verified=true
- `makeOffer(User $provider): DivingOffer`DivingOffer::create 最小必填欄位)
- [x] 1.2 [整合測試] `test_provider_creates_offer_successfully`POST 201data.provider_id = auth provider idDB 存在
- [x] 1.3 [整合測試] `test_create_offer_missing_required_field_returns_422`:缺 title 送出,回傳 422(代表性一個欄位即可)
- [x] 1.4 [整合測試] `test_provider_updates_own_offer`PUT 200DB 欄位已變更
- [x] 1.5 [整合測試] `test_provider_cannot_update_others_offer`Provider B 更新 Provider A 的課程,403
- [x] 1.6 [整合測試] `test_provider_deletes_own_offer`DELETE 200DB 記錄不存在
- [x] 1.7 [整合測試] `test_provider_cannot_delete_others_offer`403
- [x] 1.8 [整合測試] `test_unauthenticated_request_is_rejected`:無 token POST401
## 2. Provider 時段管理測試
- [x] 2.1 [整合測試] 建立 `tests/Feature/ProviderScheduleCrudTest.php`,加入 private helpers
- `makeProvider(): User`
- `makeOffer(User $provider): DivingOffer`
- `makeSchedule(DivingOffer $offer, int $currentParticipants = 0): CourseSchedule`
- [x] 2.2 [整合測試] `test_provider_creates_schedule_successfully`POST 201status=open
- [x] 2.3 [整合測試] `test_provider_cannot_create_schedule_for_others_offer`403
- [x] 2.4 [整合測試] `test_past_date_returns_422`scheduled_date = yesterday422(代表性驗證案例)
- [x] 2.5 [整合測試] `test_provider_updates_schedule_max_participants`PUT 200DB 更新
- [x] 2.6 [整合測試] `test_max_participants_below_current_returns_422`current_participants=3PUT max_participants=2422
- [x] 2.7 [整合測試] `test_provider_cannot_update_others_schedule`403
- [x] 2.8 [整合測試] `test_delete_schedule_marks_active_bookings_as_provider_cancelled`:建立一筆 pending + 一筆 confirmed bookingDELETE 200;斷言時段 status=`cancelled`(記錄保留);兩筆 booking status=`provider_cancelled`(記錄保留,不刪除);不在 pending/confirmed 的 booking 不受影響
## 3. Admin 使用者管理測試
- [x] 3.1 [整合測試] 建立 `tests/Feature/AdminUserManagementTest.php`,加入 private helpers
- `makeAdmin(): User`role=admin
- `makeMember(): User`role=member + MemberProfile
- `makeProvider(bool $isVerified = false): User`role=provider + ProviderProfile
- [x] 3.2 [整合測試] `test_admin_lists_members_only`:建立 member + provider 各一,GET /api/admin/members 回傳 data 只含 member role
- [x] 3.3 [整合測試] `test_admin_lists_providers_only`GET /api/admin/providers 回傳 data 只含 provider role
- [x] 3.4 [整合測試] `test_admin_toggles_member_active_status`:目標 is_active=true → POST toggle-active → DB is_active=false
- [x] 3.5 [整合測試] `test_admin_toggles_provider_verified_status`:目標 is_verified=false → POST toggle-verified → DB is_verified=true(不重複 DivingOfferVisibilityTest 的快取副作用驗證,只斷言 DB 欄位與 HTTP 200
- [x] 3.6 [整合測試] `test_non_admin_is_forbidden`role=member actingAsGET /api/admin/members 回傳 403
## 4. 通知直接觸發路徑測試
收件者依實際 controller 實作(已查閱原始碼 + app/Notifications/ 確認 class 存在):
- `BookingCreatedNotification``MemberBookingController::store``$provider->notify(new BookingCreatedNotification($booking))` → **教練**收到
- `BookingConfirmedNotification``ProviderBookingController::confirm``$booking->member->notify(...)` → **學員**收到
- `BookingRejectedNotification``ProviderBookingController::reject``$booking->member->notify(...)` → **學員**收到
- `BookingCancelledNotification`cancelledBy: 'member'):`MemberBookingController::destroy``$provider->notify(...)` → **教練**收到
- `BookingCancelledNotification`cancelledBy: 'provider'):`ProviderBookingController::cancel``$booking->member->notify(...)` → **學員**收到
- 注意:member/provider cancel 使用**同一個 class** `BookingCancelledNotification``assertSentTo` 只需確認 class 名稱與收件者即可
- [x] 4.1 [整合測試] 建立 `tests/Feature/NotificationTriggerTest.php`setUp 加 `Notification::fake()`;加入 helper 建立 member + provider(含 ProviderProfile + offer + scheduleopen, 未來日期) + pending booking
- [x] 4.2 [整合測試] `test_booking_created_notifies_provider`Member `POST /api/member/bookings``assertSentTo($provider, BookingCreatedNotification::class)`
- [x] 4.3 [整合測試] `test_booking_confirmed_notifies_member`Provider `PUT /api/provider/bookings/{id}/confirm``assertSentTo($member, BookingConfirmedNotification::class)`
- [x] 4.4 [整合測試] `test_booking_rejected_notifies_member`Provider `PUT /api/provider/bookings/{id}/reject``assertSentTo($member, BookingRejectedNotification::class)`
- [x] 4.5 [整合測試] `test_member_cancel_notifies_provider`Member `DELETE /api/member/bookings/{id}`pending, 遠未來日期) → `assertSentTo($provider, BookingCancelledNotification::class)`
- [x] 4.6 [整合測試] `test_provider_cancel_notifies_member`Provider `PUT /api/provider/bookings/{id}/cancel`(先 confirm 再 cancel`assertSentTo($member, BookingCancelledNotification::class)`
## 5. 預約列表端點測試
路由對應(已查閱 routes/api.php + EnsureAdmin middleware 確認):
- `GET /api/member/bookings` → middleware: `auth:sanctum`(無 role 檢查)→ `MemberBookingController::index`,隔離靠 `where member_id = auth()->id()`
- `GET /api/provider/bookings` → middleware: `auth:sanctum`(無 role 檢查)→ `ProviderBookingController::index`,隔離靠 `whereHas schedule.provider_id`
- `GET /api/admin/bookings` → middleware: `auth:sanctum` + `admin``EnsureAdmin` 強制 403)→ `AdminBookingController::index`
- [x] 5.1 [整合測試] 建立 `tests/Feature/BookingListTest.php`,加入 helpers
- `makeProviderWithSchedule(): array`(回傳 [provider, schedule],含 ProviderProfile + DivingOffer + CourseSchedule
- `makePendingBooking(User $member, CourseSchedule $schedule): Booking`
- [x] 5.2 [整合測試] `test_member_sees_only_own_bookings`MemberA 和 MemberB 各建一筆 pending booking(同一 schedule),actingAs MemberA → `GET /api/member/bookings`,斷言 data count=1 且 data[0].id = MemberA booking id
- [x] 5.3 [整合測試] `test_provider_sees_only_own_course_bookings`ProviderA 和 ProviderB 各有一筆 bookingactingAs ProviderA → `GET /api/provider/bookings`,斷言 data 中只含 ProviderA schedule 的 booking id
- [x] 5.4 [整合測試] `test_admin_sees_all_bookings`:建立 2 筆不同 member 的 bookingactingAs admin → `GET /api/admin/bookings`,斷言兩個 booking id 均在 data 中
- [x] 5.5 [整合測試] `test_unauthenticated_request_returns_401`:無 token `GET /api/member/bookings`401
## 6. 驗收
- [x] 6.1 容器內 `php artisan test` 全綠(基準 187 passed,預估完成後 ≥ 230 passed
- [x] 6.2 確認 5 個新測試類別的案例方法數與 spec scenarios 對應(可多不可少)
- [x] 6.3 確認 `phpunit.xml` 仍為 `CACHE_STORE=array`,未被意外改動
- [x] 6.4 將本 change 的 specs 增量套用至主規格 `openspec/specs/feature-test-coverage/spec.md`
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-11
@@ -0,0 +1,57 @@
# Design — provider-verification-workflow
## D1:四狀態而非三狀態
路線圖原列 pending/approved/rejected 三態,但「註冊後補件送審」決策(Hank 2026-06-11 拍板)使「註冊完還沒送審」與「已送審待審」必須區分——Admin 佇列只該出現後者。故:
```
unsubmitted ──submit──> pending ──approve──> approved
↑ │ │
│ reject reject(撤銷,原因必填)
│ ↓ ↓
└──(重新上傳)── rejected ──submit──> pending
```
- 合法轉移:`unsubmitted→pending``pending→approved|rejected``rejected→pending``approved→rejected`(撤銷)
- `rejected→pending` 重送時清空 `rejection_reason`
- 欄位用 string + 應用層常數(與 BookingStatus enum 同模式,建 `App\Enums\VerificationStatus`
## D2:取代而非並存 boolean
`is_verified` 欄位**移除**migration: true→approved、false→unsubmitted),不留同步欄位——兩個真實來源必然漂移(Rule 7)。API 相容由 `ProviderProfile::$appends``is_verified` accessor 提供(`= status === approved`),前端/Swagger 既有讀取點不破壞;查詢端(`visibleToPublic`、預約入口)改查 `verification_status`
## D3:證照獨立資料表
`provider_certifications`id, user_id, image_path, created_at)。不塞 JSON 欄位:要逐張刪除、要 FK 清理、上限 3 張需 count 查詢。圖片複用 `CompressesImages::compressToJpeg`(O3.1 的 trait,第二個使用者,驗證抽共用的價值),存 `providers/{user_id}/certifications/`。刪除政策:`unsubmitted`/`rejected` 可增刪;`pending`/`approved` 鎖定(審核依據不可變動)。
## D4:端點設計
**Providerauth:sanctum + provider prefix**
- `GET /api/provider/verification`——status、rejection_reason、certifications[]
- `POST /api/provider/verification/certifications`——上傳(≤3 張、≤10MB、壓縮);僅 unsubmitted/rejected
- `DELETE /api/provider/verification/certifications/{id}`——僅 unsubmitted/rejected、僅本人
- `POST /api/provider/verification/submit`——至少 1 張證照;unsubmitted/rejected → pending
**Adminauth:sanctum + admin**
- `GET /api/admin/verifications?status=pending`——佇列(預設 pending,可查全部狀態),含教練資料與證照 URL
- `PUT /api/admin/verifications/{userId}/approve`——pending → approved
- `PUT /api/admin/verifications/{userId}/reject`——pending/approved → rejected`reason` 必填(max 500
approve/reject 皆 flush `diving_offers` 快取 tag(可見性立即生效,沿用 toggle 的既有做法)。`toggle-verified` 端點與 `AdminUserController::toggleProviderVerified` 移除——保留會提供繞過狀態機的後門。
控制器:新建 `ProviderVerificationController``AdminVerificationController`(單一職責),不塞進 AuthController/AdminUserController。
## D5:通知
`ProviderVerificationApprovedNotification``ProviderVerificationRejectedNotification`(含原因),照 Booking* 模式(database + mail channeltry/catch 包裹不阻斷主流程)。送審時**不**通知 Admin(Admin 有佇列頁;避免通知氾濫),列為未來選項。
## D6:前端
- **教練端** `views/coach/VerificationView.vue`route `/coach/verification`):狀態卡(四態 + 駁回原因)、證照上傳/刪除(沿用 OfferFormView 的圖片上傳模式)、送審按鈕;CoachLayout 側欄加入口與狀態 badge
- **Admin 端** `views/admin/ProvidersView.vue` 擴充:狀態 badge 四態化、「待審核」filter、展開查看證照圖、通過/駁回操作(駁回原因用 inline 輸入,沿用該頁既有操作風格);不另開新頁(教練列表即審核入口,避免兩頁資料重複)
## D7Demo 資料與測試遷移
- DemoSeeder3 位已驗證 → `approved`;1 位未驗證 → 給 1 張證照 + `pending`(展示審核佇列)
- 既有測試 helperDivingOfferVisibility / BookingLifecycle / BookingOversell / ReviewTest):`is_verified => true/false``verification_status => approved/unsubmitted`
- 新測試:送審狀態機(含非法轉移)、證照上限與鎖定、Admin 審核權限邊界、通知發送、approve/reject 後可見性立即生效
@@ -0,0 +1,26 @@
## Why
目前教練驗證只有一顆 Admin 的 `is_verified` 開關(2026-06-11 修補後具備可見性約束力),但**沒有審核「流程」**:教練無從提交資質證明、Admin 沒有審核依據與佇列、駁回無原因可言、結果無通知。路線圖(`docs/analysis/2026-06-11-future-roadmap-feasibility.md` §2.1)將此列為金流前的信任基礎建設。
## What Changes
- **資料模型**`provider_profiles.is_verified`boolean)升級為 `verification_status` 狀態機——`unsubmitted`(註冊預設)→ `pending`(已送審)→ `approved` / `rejected`(含 `rejection_reason`);新增 `provider_certifications` 資料表存證照圖片
- **教練端**:後台新增「驗證申請」頁——上傳證照(1~3 張,複用 `CompressesImages` 壓縮管線)、送審、查看駁回原因、重新送審(產品決策:**註冊後補件送審**,不擋註冊流程)
- **Admin 端**:審核佇列(pending 優先)、查看證照、通過/駁回(駁回原因必填);移除舊 `toggle-verified` 開關端點(與狀態機衝突)
- **通知**:審核通過/駁回通知教練(站內 + Email,複用 notification 管線)
- **相容性**`ProviderProfile` 以 accessor 保留 API 輸出的 `is_verified` 欄位(= status 是否為 approved);`visibleToPublic` scope 與預約入口檢查改查 `verification_status = 'approved'`,語意不變
## Capabilities
### Modified Capabilities
- `provider-verification`:boolean 語意升級為四狀態機;新增教練送審端點、Admin 審核端點、通知 requirements;可見性規則改以 `approved` 判定(行為等價)
- `admin-user-management``toggle-verified` 端點移除,由 approve / reject 取代
## Impact
- **資料庫**migration 轉換既有資料(`is_verified=true → approved``false → unsubmitted`)、新表 `provider_certifications`
- **後端**:新增 `ProviderVerificationController``AdminVerificationController`、2 個 Notification class;修改 `DivingOffer::visibleToPublic``MemberBookingController``AdminUserController`(移除 toggle)、`ProviderProfile`、兩個 Seeder
- **前端**:新增 `coach/VerificationView.vue`;改 `admin/ProvidersView.vue`(狀態 badge、審核操作)
- **測試**:既有用到 `is_verified` 的 4 個測試檔改用 `verification_status`;新增送審流程與審核權限測試
- **行為變更**:Admin 不再能單鍵切換驗證,必須走審核流(撤銷 = 駁回既有 approved 教練並附原因);既有預約不受狀態變動影響(沿用 provider-verification 規格既定政策)
@@ -0,0 +1,14 @@
## MODIFIED Requirements
### Requirement: 管理員切換教練驗證狀態
(本 requirement 由 provider-verification 的「Admin 審核佇列與裁決」取代)
`PUT /api/admin/providers/{id}/toggle-verified` SHALL 移除——單鍵切換允許繞過審核狀態機(無原因駁回、未送審直接通過)。教練驗證狀態的變更一律透過 `PUT /api/admin/verifications/{userId}/approve|reject`
#### Scenario: toggle 端點回 404
- **WHEN** Admin 請求 `PUT /api/admin/providers/{id}/toggle-verified`
- **THEN** 回傳 HTTP 404
#### Scenario: 教練列表仍顯示驗證狀態
- **WHEN** Admin 請求 `GET /api/admin/providers`
- **THEN** 每筆教練資料包含 `verification_status`(與相容用 `is_verified` accessor
@@ -0,0 +1,84 @@
## ADDED Requirements
### Requirement: 教練驗證狀態機
`provider_profiles.verification_status` SHALL 為四狀態字串:`unsubmitted`(註冊預設)、`pending``approved``rejected`。合法轉移僅限:`unsubmitted→pending`(送審)、`pending→approved`(通過)、`pending→rejected`(駁回)、`rejected→pending`(重新送審,同時清空 `rejection_reason`)、`approved→rejected`(撤銷,原因必填)。原 boolean `is_verified` 欄位移除,API 輸出以 accessor 保留 `is_verified`= status 為 approved)。
#### Scenario: 新教練註冊預設未送審
- **WHEN** 教練完成註冊
- **THEN** `verification_status = unsubmitted`,不出現在 Admin 待審佇列
#### Scenario: 非法轉移被拒絕
- **WHEN** 嘗試對 `unsubmitted` 教練執行 approve,或對 `approved` 教練執行 approve
- **THEN** 回傳 HTTP 422,狀態不變
---
### Requirement: 教練上傳證照與送審
教練 SHALL 能於後台上傳 1~3 張證照圖片(jpeg/png/webp、≤10MB,伺服器端壓縮為 `.jpg`,存 `providers/{user_id}/certifications/`)並送出審核。證照僅於 `unsubmitted` / `rejected` 狀態可增刪;`pending` / `approved` 狀態鎖定。送審需至少 1 張證照。
#### Scenario: 上傳證照
- **WHEN** `unsubmitted` 教練 `POST /api/provider/verification/certifications` 上傳合法圖片且現有 < 3 張
- **THEN** 壓縮儲存並建立 `provider_certifications` 紀錄,回傳圖片資訊
#### Scenario: 超過 3 張上限
- **WHEN** 教練已有 3 張證照再上傳
- **THEN** 回傳 HTTP 422
#### Scenario: 送審成功
- **WHEN** 教練至少有 1 張證照,`POST /api/provider/verification/submit`
- **THEN** 狀態轉為 `pending``rejection_reason` 清空
#### Scenario: 無證照不可送審
- **WHEN** 教練無任何證照即送審
- **THEN** 回傳 HTTP 422,狀態不變
#### Scenario: pending 期間證照鎖定
- **WHEN** `pending``approved` 教練嘗試上傳或刪除證照
- **THEN** 回傳 HTTP 422
#### Scenario: 查詢自身驗證狀態
- **WHEN** 教練 `GET /api/provider/verification`
- **THEN** 回傳 `status``rejection_reason``certifications[]`id、url
---
### Requirement: Admin 審核佇列與裁決
Admin SHALL 能查詢審核佇列(`GET /api/admin/verifications?status=pending`,預設 pending,含教練資料與證照 URL),並對 `pending` 教練執行通過(`PUT /api/admin/verifications/{userId}/approve`)或駁回(`PUT /api/admin/verifications/{userId}/reject``reason` 必填、max 500)。Admin SHALL 能駁回 `approved` 教練(撤銷驗證,原因必填)。裁決後 SHALL flush `diving_offers` 快取(可見性立即生效)。原 `PUT /api/admin/providers/{id}/toggle-verified` 端點 SHALL 移除。
#### Scenario: 通過審核
- **WHEN** Admin 對 pending 教練執行 approve
- **THEN** 狀態轉 `approved`,教練課程立即恢復公開可見性
#### Scenario: 駁回需附原因
- **WHEN** Admin 執行 reject 未帶 `reason`
- **THEN** 回傳 HTTP 422,狀態不變
#### Scenario: 撤銷已通過教練
- **WHEN** Admin 對 approved 教練執行 reject 並附原因
- **THEN** 狀態轉 `rejected`,課程立即從公開列表消失;既有預約照常(沿用既定政策)
#### Scenario: 舊 toggle 端點已移除
- **WHEN** 任何人請求 `PUT /api/admin/providers/{id}/toggle-verified`
- **THEN** 回傳 HTTP 404
---
### Requirement: 審核結果通知
系統 SHALL 於 approve / reject 後通知該教練(站內 + Email):通過通知告知課程已可公開曝光;駁回通知包含駁回原因。通知失敗不阻斷審核主流程。
#### Scenario: 通過通知
- **WHEN** Admin approve
- **THEN** 教練收到站內通知與 Email,內容含審核通過訊息
#### Scenario: 駁回通知含原因
- **WHEN** Admin reject 並附原因
- **THEN** 教練收到的通知內容包含該原因
## MODIFIED Requirements
### Requirement: 未驗證教練的課程不對公開端點曝光
公開課程端點(列表、詳情、schedules、reviewsSHALL 僅曝光 `provider_id` 為 null 或教練 `verification_status = 'approved'` 的課程(原以 `is_verified = true` 判定,語意等價)。`POST /api/member/bookings` 的可預約檢查同步改以 `approved` 判定。其餘行為(404、422、既有預約不受影響)不變。
#### Scenario: 非 approved 狀態一律不曝光
- **WHEN** 課程教練的狀態為 unsubmitted / pending / rejected
- **THEN** 公開端點不回傳該課程,新預約被拒
@@ -0,0 +1,47 @@
## 1. 資料模型
- [x] 1.1 新增 `App\Enums\VerificationStatus`Unsubmitted / Pending / Approved / Rejected,含 `VALID_TRANSITIONS``canTransitionTo()`,仿 BookingStatus 模式)
- [x] 1.2 Migration`provider_profiles``verification_status`string, default 'unsubmitted')與 `rejection_reason`text nullable);資料轉換 `is_verified=true→approved``false→unsubmitted`drop `is_verified`
- [x] 1.3 Migration:新表 `provider_certifications`id, user_id FK cascade, image_path, timestamps
- [x] 1.4 `ProviderProfile`fillable/casts 更新、`is_verified` accessorappends= approved)、`certifications` 由 User 關聯
- [x] 1.5 新 Model `ProviderCertification`belongsTo User、url accessor,仿 CourseImage
## 2. 教練端 API
- [x] 2.1 `ProviderVerificationController``show`status + reason + certifications)、`uploadCertification`(≤3 張、複用 CompressesImages、僅 unsubmitted/rejected)、`deleteCertification`(僅本人 + 僅 unsubmitted/rejected)、`submit`(≥1 張證照、unsubmitted/rejected→pending、清 rejection_reason
- [x] 2.2 routes`/api/provider/verification*` 四端點(auth:sanctum + provider group
## 3. Admin 端 API
- [x] 3.1 `AdminVerificationController``index`?status= 預設 pending,含教練資料與證照)、`approve`pending→approved)、`reject`pending|approved→rejectedreason 必填 max 500);approve/reject flush diving_offers 快取 + 通知教練
- [x] 3.2 routes`/api/admin/verifications*` 三端點;移除 toggle-verified route 與 `AdminUserController::toggleProviderVerified`
- [x] 3.3 `AdminUserController::providers` 列表回應含 `verification_status`
## 4. 可見性判定切換
- [x] 4.1 `DivingOffer::visibleToPublic``is_verified=true``verification_status='approved'`
- [x] 4.2 `MemberBookingController::store` 可預約檢查同步改判 approved
## 5. 通知
- [x] 5.1 `ProviderVerificationApprovedNotification` + `ProviderVerificationRejectedNotification`(含原因;database + mail,仿 Booking* 模式)
## 6. Seeder
- [x] 6.1 DemoSeeder / DevelopmentSeeder`is_verified``verification_status`;demo 第 4 位教練給 1 張證照 + pending(展示佇列)
## 7. 前端
- [x] 7.1 `views/coach/VerificationView.vue` + route `/coach/verification`:狀態卡(四態 + 駁回原因)、證照上傳/刪除、送審;CoachLayout 側欄入口
- [x] 7.2 `views/admin/ProvidersView.vue`badge 四態化、待審 filter、查看證照、通過/駁回(原因輸入);移除 toggle 呼叫
## 8. 測試
- [x] 8.1 既有測試遷移:DivingOfferVisibilityTest / BookingLifecycleTest / BookingOversellTest / ReviewTest 的 helper 改 `verification_status`
- [x] 8.2 `ProviderVerificationTest`(新):狀態機合法/非法轉移、證照上限/鎖定/本人限定、送審前置條件、重送清空原因
- [x] 8.3 `AdminVerificationTest`(新):佇列過濾、approve/reject 權限(非 admin 403)、reject 無原因 422、撤銷 approved、裁決後可見性立即生效(快取)、通知發送斷言、toggle 端點 404
- [x] 8.4 容器內全套件綠(基準 155 passed)
## 9. 規格同步
- [x] 9.1 specs 增量套用至主規格 `provider-verification``admin-user-management`
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-17
@@ -0,0 +1,151 @@
## Context
P0 完成後 VPS 目前狀態:
- Session`SESSION_DRIVER=database`MySQL
- Log`LOG_CHANNEL=stack``LOG_STACK=daily`(寫檔案)
- Schedulercron daemon 跑在 app container 內,entrypoint 啟動
三項都是雲端不友善的設定:多實例時 cron 重複執行、檔案 log 無法被雲端 aggregator 收集、DB session 擴容效率低。
**Bind mount 暫時保留**`./:/var/www` 目前仍保留,移除 bind mount 屬於 P0 剩餘項目(需將 code bake 進 image),本 change 不處理。scheduler service 同樣沿用 bind mount,與 app/queue-worker 一致。
## Goals / Non-Goals
**Goals:**
- Session 存 Redis,擴容時不需共享 DB session table
- Log 走 stderr`docker compose logs` 及雲端 aggregator 皆可收
- Scheduler 獨立 container,水平擴容 app 時不會重複跑定時任務
- Dockerfile 移除 cron 依賴,image 更精簡
**Non-Goals:**
- 不處理 S3 檔案儲存(P0 待辦)
- 不移除 bind mountP0 待辦)
- 不動任何 PHP 業務邏輯、config/ 目錄下的 PHP 設定檔
## Pre-flight 確認(實作前已驗證)
| 項目 | 結果 |
|---|---|
| `config/logging.php``stderr` channel | ✅ 已存在(line 97),不需改 code |
| `config/session.php``driver``.env` | ✅ `env('SESSION_DRIVER', 'database')`line 21 |
| `SESSION_CONNECTION` | 預設 `null`(使用 default Redis connection),VPS Redis 已在跑,不需額外設定 |
## Decisions
### D1Scheduler 用 `php artisan schedule:work` 而非 crontab
`schedule:work` 是 Laravel 10+ 內建指令,在前台以無限迴圈每分鐘執行一次 `schedule:run`,不需要 cron daemon。符合 12-factor「一個 container 跑一個 process」原則。
**`depends_on` 取捨**scheduler 設 `depends_on: app: condition: service_healthy`,確保首次啟動時 PHP-FPM 就緒後 scheduler 才開始執行。注意:`depends_on` 只控制啟動順序,不控制運行時依賴——app container 重啟時 scheduler **不會**跟著重啟,會繼續跑;若 app 還沒 ready 而 scheduler 已觸發任務,任務本身可能失敗,但 scheduler 程序不會崩潰。這是可接受的,定時任務本身應有冪等與錯誤處理。
```yaml
scheduler:
image: cfdive-platform
container_name: cfdive-scheduler
restart: unless-stopped
working_dir: /var/www/
command: php artisan schedule:work
volumes:
- ./:/var/www
environment:
- DB_CONNECTION=mysql
- DB_HOST=db
- DB_PORT=3306
- DB_DATABASE=${DB_DATABASE:-CFDivePlatform}
- DB_USERNAME=${DB_USERNAME:-cfdiveuser}
- DB_PASSWORD=${DB_PASSWORD}
networks:
- cfdive-network
depends_on:
app:
condition: service_healthy
```
### D2Log 改 `LOG_CHANNEL=stderr`
`config/logging.php` 已內建 `stderr` channel`php://stderr`),Docker 自動對應到 container stderr stream`docker compose logs app` 即可看到。不需新增任何 PHP 設定。
`.env.example` 同步移除 `LOG_STACK``LOG_DAILY_DAYS`(改 stderr 後這兩個無作用,留著造成混淆)。
**Config cache 注意**VPS `.env` 改完後需清除 config cache 才能生效:
```bash
docker compose exec app php artisan config:clear
```
或直接 `docker compose up -d --build`entrypoint 會自動 `config:clear`)。
### D3Session 改 Redis,不需建新資料表
Redis 已在跑(`cfdive-redis`),`config/session.php``.env`,開箱即用。`sessions` table 保留不刪(避免 rollback 需要重建)。
### D4VPS `.env` 更新方式
選手動 SSH 更新(不進 deploy.yml 自動化),原因:`.env` 含機敏值,CI 自動覆寫風險高;此為一次性變更。
## Risks / Trade-offs
- **[Risk] Session 失效**:改 driver 後所有現有 session 立即失效,用戶需重新登入。
**Mitigation**:排低流量時段;VPS `.env` 改完後直接 rebuild,縮短空窗。
- **[Risk] Rebuild image 期間短暫停機**Dockerfile 移除 cron 需 rebuild。
**Mitigation**`docker compose up -d --build` 滾動更新,停機窗口約 30 秒。
- **[Risk] Config cache 未清除**`.env` 改了但 `bootstrap/cache/config.php` 仍是舊值,session/log 不生效。
**Mitigation**Migration Plan 步驟明確包含 `config:clear`entrypoint 在 rebuild 後也會自動清除。
- **[Risk] Log 遺失歷史**:改 stderr 後 `storage/logs/` 不再更新,但舊檔保留在磁碟。
**Mitigation**:Sentry 持續收錯誤;需要查歷史時直接看舊檔案。
## Acceptance Criteria
- [ ] `docker compose ps` 顯示 `cfdive-scheduler` 容器 Up
- [ ] `docker compose exec app sh -c "which cron 2>/dev/null || echo 'cron binary not found'"` 輸出 `cron binary not found`binary 已從 image 移除)
- [ ] 呼叫任意 API 後,`docker compose logs app` 有日誌輸出(stderr log 生效)
- [ ] `storage/logs/` 下無新增日誌檔(舊檔保留)
- [ ] 登入後 `docker compose exec redis redis-cli keys "laravel_session*"` 有輸出(session 走 Redis
## Migration Plan
**程式碼(PR 合併前):**
1. `Dockerfile`:移除 cron 安裝與 crontab 設定
2. `docker/php/docker-entrypoint.sh`:移除 `service cron start`
3. `docker-compose.yml`:新增 `scheduler` service
4. `.env.example``SESSION_DRIVER=redis``LOG_CHANNEL=stderr`、移除 `LOG_STACK`/`LOG_DAILY_DAYS`
5. `docs/STARTUP.md`:補充 scheduler、log 查看、VPS 操作說明
**VPS 維護窗口(PR merge + CI/CD 完成後):**
```bash
# SSH 進 VPS
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
docker compose logs app | tail -20
docker compose exec redis redis-cli keys "laravel_session*"
```
## Rollback Plan
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
```
@@ -0,0 +1,31 @@
## Why
P0 零影響改動完成後,P1 處理三個需要維護窗口但改完後功能完全等效的雲端就緒項目:Session 存 Redis 取代 MySQL(擴容必要)、Log 走 stdout 取代檔案(雲端 logging 標準)、Scheduler 抽成獨立 container 取代 app 容器內 cron(水平擴容不重複執行)。
## What Changes
- **`.env.example`**`SESSION_DRIVER` 預設改 `redis``LOG_CHANNEL` 預設改 `stderr`
- **`Dockerfile`**:移除 `cron` 安裝、移除 scheduler cron job 寫入
- **`docker/php/docker-entrypoint.sh`**:移除 `service cron start`
- **`docker-compose.yml`**:新增 `scheduler` service`php artisan schedule:work`
- **`docs/STARTUP.md`**:更新服務清單,說明 scheduler container 與 log 查看方式
- **VPS `.env`**:需在維護窗口更新 `SESSION_DRIVER=redis``LOG_CHANNEL=stderr`(不進版控)
## Capabilities
### New Capabilities
- `scheduler-container`Scheduler 以獨立 container 方式執行,不依賴 app container 內的 cron daemon
### Modified Capabilities
- `env-cloud-annotations`:補充 `SESSION_DRIVER``LOG_CHANNEL` 的正確雲端預設值(原 spec 已標注說明,現在升級為實際預設值)
## Impact
- **`Dockerfile`**:移除 cron 相關 2 行,需 rebuild image
- **`docker/php/docker-entrypoint.sh`**:移除 1 行
- **`docker-compose.yml`**:新增 `scheduler` service
- **`.env.example`**2 個預設值變動
- **不影響**:所有 PHP 業務邏輯、API 行為、前端、資料庫 schema
- **部署影響**:Session 失效一次(用戶登出)、需 rebuild app image
@@ -0,0 +1,27 @@
## MODIFIED Requirements
### Requirement: QUEUE_CONNECTION 預設值為 redis
`.env.example``QUEUE_CONNECTION` SHALL 預設為 `redis`,與 VPS 實際運作設定一致,避免新環境按範本初始化後跑在效能較差的 database queue。
#### Scenario: 新環境照 .env.example 初始化
- **WHEN** 開發者或 CI 以 `.env.example` 為基礎建立 `.env`,未手動修改 `QUEUE_CONNECTION`
- **THEN** Laravel queue driver 使用 Redisqueue worker 正常消費 Redis 佇列中的 job
### Requirement: .env.example 標示雲端必要變數
`.env.example` SHALL 以行內註解或正確預設值標示以下雲端部署時必須明確設定的變數:
- `FILESYSTEM_DISK`:雲端應設為 `s3`(預設 `local` 在容器重啟後遺失上傳檔案)
- `LOG_CHANNEL`:預設改為 `stderr`(雲端 logging aggregator 標準;原 `stack/daily` 寫檔案不適合雲端)
- `QUEUE_CONNECTION`:預設為 `redis`
- `SESSION_DRIVER`:預設改為 `redis`(取代 `database`,擴容更高效)
#### Scenario: 新環境照 .env.example 初始化 session driver
- **WHEN** 開發者以 `.env.example` 為基礎建立 `.env`,未手動修改 `SESSION_DRIVER`
- **THEN** Laravel session driver 使用 Redis
#### Scenario: 新環境照 .env.example 初始化 log channel
- **WHEN** 開發者以 `.env.example` 為基礎建立 `.env`,未手動修改 `LOG_CHANNEL`
- **THEN** Laravel 日誌寫入 stderr,可透過 `docker compose logs app` 查看
#### Scenario: 操作者閱讀 .env.example 進行雲端部署
- **WHEN** 操作者參照 `.env.example` 設定雲端環境的 `.env`
- **THEN** 每個雲端關鍵變數旁有說明,提示預設值在雲端環境的限制與建議替代值
@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: Scheduler 以獨立 container 執行
`docker-compose.yml` SHALL 定義 `scheduler` service,使用 `cfdive-platform` image,以 `php artisan schedule:work` 前台輪詢方式執行 Laravel Scheduler,取代原本在 app container 內的 cron daemon。
#### Scenario: Scheduler container 隨 app 啟動
- **WHEN** 執行 `docker compose up -d``app` container 已 healthy
- **THEN** `scheduler` container 啟動並持續執行 `php artisan schedule:work`
#### Scenario: App container 重啟時 scheduler 等待就緒
- **WHEN** `app` container 重啟中(unhealthy
- **THEN** `scheduler` container 依 `depends_on` 等待 `app` healthy 後才啟動,避免 scheduler 在 PHP-FPM 未就緒時執行任務
### Requirement: App container 不再包含 cron daemon
`Dockerfile` SHALL 不安裝 `cron` 套件,`docker-entrypoint.sh` SHALL 不執行 `service cron start`,確保 app container 職責單一(只跑 PHP-FPM)。
#### Scenario: App container 啟動不啟動 cron
- **WHEN** `app` container 啟動並執行 entrypoint
- **THEN** 不存在 cron daemon 程序,`pgrep cron` 回傳 exit code 1
@@ -0,0 +1,45 @@
## 1. Dockerfile 移除 cron
- [x] 1.1 [後端] 從 `Dockerfile``apt-get install -y` 清單移除 `cron`
- [x] 1.2 [後端] 從 `Dockerfile` 移除寫入 `/etc/cron.d/laravel-scheduler``RUN echo ...` 指令
- [x] 1.3 [後端] 從 `Dockerfile` 移除 `crontab /etc/cron.d/laravel-scheduler` 指令
## 2. docker-entrypoint.sh 移除 cron 啟動
- [x] 2.1 [後端] 從 `docker/php/docker-entrypoint.sh` 移除 `service cron start || cron || true` 這一行
## 3. docker-compose.yml 新增 scheduler service
- [x] 3.1 [後端] 在 `docker-compose.yml` 新增 `scheduler` service,設定如下:
- `image: cfdive-platform``container_name: cfdive-scheduler``restart: unless-stopped`
- `working_dir: /var/www/``command: php artisan schedule:work`
- `volumes: - ./:/var/www`
- 環境變數:`DB_CONNECTION``DB_HOST=db``DB_PORT``DB_DATABASE``DB_USERNAME``DB_PASSWORD`(與 queue-worker 相同)
- `networks: - cfdive-network`
- `depends_on: app: condition: service_healthy`
## 4. .env.example 更新
- [x] 4.1 [後端] 將 `.env.example``SESSION_DRIVER=database # 雲端建議改為 redis` 改為 `SESSION_DRIVER=redis`(升級為預設值,移除說明)
- [x] 4.2 [後端] 將 `.env.example``LOG_CHANNEL=stack` 改為 `LOG_CHANNEL=stderr`
- [x] 4.3 [後端] 從 `.env.example` 移除 `LOG_STACK=daily``LOG_DAILY_DAYS=14` 兩行(改 stderr 後無作用)
## 5. 文件更新
- [x] 5.1 [後端] 更新 `docs/STARTUP.md`,補充以下內容:
- 服務清單新增 `scheduler``docker compose ps` 應見 `cfdive-scheduler`
- Log 查看方式:`docker compose logs -f app`(不再有 `storage/logs/` 檔案)
- **VPS 維護窗口操作**:說明 `sed` 更新 `.env` 的四個指令 + `docker compose up -d --build`
- **Config cache 注意**`.env` 改完須清 cacherebuild 時 entrypoint 自動處理)
- **Rollback 步驟**Session/Log 還原指令(不需 rebuild+ Scheduler 還原(需 rebuild
## 6. 手動驗證(本機,不需 VPS .env)
- [x] 6.1 [整合測試] 在本機 rebuild 後確認 `cfdive-scheduler` 正常啟動:`docker compose ps` 顯示 scheduler Up
- [x] 6.2 [整合測試] 確認 app container 無 cron`docker compose exec app sh -c "which cron 2>/dev/null || echo 'cron binary not found'"`cron binary 已從 image 移除,輸出 `cron binary not found`
## 7. 手動驗證(VPS,需更新 .env 後)
- [x] 7.1 [整合測試] 確認 log 走 stderr:呼叫任意 API 後 `docker compose logs app | tail -20` 有日誌輸出
- [x] 7.2 [整合測試] 確認 `storage/logs/` 無新增日誌檔(舊檔保留)
- [x] 7.3 [整合測試] 確認 session 走 Redis:登入後 `docker compose exec redis redis-cli keys "cfdiveplatform_database_*"` 有輸出(prefix 為 APP_NAME_database_,非 laravel_session*
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-17
@@ -0,0 +1,76 @@
## Context
CFDivePlatform 跑在單台 VPS 上,全部服務以 Docker Compose 管理。現有設定有三個雲端就緒問題:
1. `app` service healthcheck 用 `php -v`,只驗證 binary 存在,無法確認 PHP-FPM 是否監聽
2. `phpmyadmin``mailpit` 這兩個開發/管理工具寫死在 `docker-compose.yml`,雲端正式環境不應存在
3. `.env.example``QUEUE_CONNECTION=database` 與 VPS 實際使用 Redis 不一致,新環境按範本初始化會跑在效能較差的 DB queue
本次只改 compose 設定和 `.env.example`,不觸碰任何 PHP 程式碼。
## Goals / Non-Goals
**Goals:**
- healthcheck 改為確認 PHP-FPM FastCGI port 9000 正常監聽,比 `php -v` 更能反映 FPM 是否就緒
- 透過 `compose.override.yml` 機制隔離開發工具,VPS 行為不變,雲端部署只需 `-f docker-compose.yml`
- `.env.example` 的 queue 預設值與雲端慣例對齊,並補充雲端必要變數的說明
**Non-Goals:**
- 不修改 Session driver(留待 P1 change
- 不修改 Log 輸出方式(留待 P1 change
- 不修改 Scheduler 架構(留待 P1 change
- 不改任何 PHP 程式碼或 image 內容
- 不處理 S3 檔案儲存(P0 項目,需獨立 change)
## Decisions
### D1healthcheck 用 `nc -z 127.0.0.1 9000` 檢查 PHP-FPM port
**為什麼不用 `/health` HTTP 端點:**
app container 跑的是 PHP-FPMFastCGIport 9000),容器內**沒有 HTTP server**。
`/health` 路由必須經過 Nginx 才能到達 PHP-FPM,而 Nginx 是另一個 container。
所以 `curl``wget``http://127.0.0.1/health` 在 app container 內部完全打不到任何東西。
**為什麼不用 `wget`**
Dockerfile 安裝清單確認 `wget` 未安裝(僅裝 `curl`)。Nginx container 的 `wget` 是 busybox-wgetAlpine),兩者環境不同,不可混用。
**選定方案:**
```yaml
healthcheck:
test: ["CMD-SHELL", "nc -z 127.0.0.1 9000 || exit 1"]
```
`netcat-openbsd` 已在 Dockerfile 安裝。`nc -z` 只做 TCP connect,確認 PHP-FPM 的 FastCGI port 正在監聽,是 app container 內部能做到最準確的 FPM 就緒判斷。
**能力邊界:** 此 healthcheck 確認 PHP-FPM 程序已啟動並監聽 port,但不驗證 NginxNginx 有自己的獨立 healthcheck。
### D2`compose.override.yml` 放在 repo 根目錄並加進版控
**選項 A**:放進 `.gitignore`,每台機器手動建立
**選項 B**:放進 repo,所有環境共用
選 B。`compose.override.yml` 只定義開發工具(phpmyadmin、mailpit),不含任何 secret 或環境特定值,放進版控讓新成員 `git clone` 後直接可用。雲端部署 CI 指定 `-f docker-compose.yml` 排除即可。
### D3`mailpit` 同步移入 override
`mailpit``phpmyadmin` 性質相同(開發輔助工具),一起移入 override,保持 `docker-compose.yml` 只含正式服務。
## Risks / Trade-offs
- **[Risk] healthcheck 只驗證 TCP 連線,不驗證請求成功**:`nc -z` 確認 port 開著,但不確認 PHP-FPM 能正確處理 FastCGI 請求。
**Mitigation**:對 `depends_on` 的用途(等待 FPM 啟動再起 Nginx)已足夠;若要更深層驗證需用 `cgi-fcgi` 工具(過度工程,暫不引入)。
- **[Risk] VPS 若沒有 `compose.override.yml`**:移除後首次 `docker compose up` 會少 phpmyadmin/mailpit。
**Mitigation**`compose.override.yml` 已進版控,VPS 執行 `git pull` 即可取得,不需手動建立。STARTUP.md 同步更新說明此流程。
## Migration Plan
1. `docker-compose.yml`:改 healthcheck、移除 phpmyadmin service、移除 mailpit service
2. 新增 `compose.override.yml`:加入 phpmyadmin、mailpit
3. `.env.example`:改 QUEUE_CONNECTION 預設值、補說明
4. `STARTUP.md`:新增「部署模式」章節,明確區分:
- **VPS(預設)**`docker compose up -d`,自動合併 override,含 phpmyadmin + mailpit
- **雲端正式環境**`docker compose -f docker-compose.yml up -d`,排除開發工具
5. **VPS 部署**:執行 `git pull` 取得版控內的 `compose.override.yml`,不需 rebuild image`docker compose up -d` 即可
## Open Questions
無。本次改動範圍明確,無待決事項。
@@ -0,0 +1,27 @@
## Why
現有 Docker Compose 設定存在若干與雲端部署不相容的配置(開發工具混入正式服務、healthcheck 無法真實反映 app 狀態、queue 預設值不一致),在不動任何 PHP 程式碼、不重建 image、不影響 VPS 現有運作的前提下,先修正這四個零風險項目,作為雲端就緒路線圖的第一步。
## What Changes
- **`.env.example`**`QUEUE_CONNECTION` 預設值從 `database` 改為 `redis`;補充 `FILESYSTEM_DISK` 與 log 相關變數的說明與分組,標記哪些是雲端部署時必須設定的項目
- **`docker-compose.yml`**app service healthcheck 從 `php -v` 改為呼叫 `/health` 端點;移除 `phpmyadmin` service
- **新增 `compose.override.yml`**:將 `phpmyadmin``mailpit` 放入 override 檔,VPS/開發環境自動載入,正式雲端部署時只用 `-f docker-compose.yml` 即可排除
## Capabilities
### New Capabilities
- `compose-cloud-baseline`Docker Compose 雲端基準設定——healthcheck 使用真實端點、正式 compose 不含開發工具、dev-only 服務透過 override 隔離
- `env-cloud-annotations``.env.example` 補充雲端部署必要環境變數的說明與正確預設值
### Modified Capabilities
(本次不修改任何既有 spec 的需求行為)
## Impact
- **`docker-compose.yml`**healthcheck 指令、移除 phpmyadmin service
- **新增 `compose.override.yml`**phpmyadmin、mailpit service 定義搬移至此
- **`.env.example`**QUEUE_CONNECTION 預設值、新增說明行
- **不影響**:所有 PHP 程式碼、VPS `.env`、image 內容、Scheduler、Session、Log
@@ -0,0 +1,32 @@
## ADDED Requirements
### Requirement: App service healthcheck 驗證 PHP-FPM FastCGI port
`docker-compose.yml``app` service healthcheck SHALL 使用 `nc -z 127.0.0.1 9000` 確認 PHP-FPM FastCGI port 正在監聽,而非執行 `php -v`(只驗證 binary 存在)。
注意:app container 內無 HTTP server,無法透過 HTTP 打 `/health``netcat-openbsd` 已在 Dockerfile 安裝。
#### Scenario: PHP-FPM 啟動後 healthcheck 通過
- **WHEN** `app` container 啟動且 PHP-FPM 程序已監聽 port 9000
- **THEN** `nc -z 127.0.0.1 9000` 回傳 exit code 0container 標記為 healthy
#### Scenario: PHP-FPM 未啟動時 healthcheck 失敗
- **WHEN** `app` container 啟動但 PHP-FPM 程序尚未就緒(port 9000 未開)
- **THEN** `nc -z 127.0.0.1 9000` 回傳非 0 exit codecontainer 維持 unhealthy 狀態直到重試成功
### Requirement: 正式 compose 不含開發工具服務
`docker-compose.yml` SHALL 不包含 `phpmyadmin``mailpit` service 定義。這兩個服務屬開發/管理工具,不得出現在正式雲端部署的 compose 設定中。
#### Scenario: 只使用 docker-compose.yml 啟動
- **WHEN** 執行 `docker compose -f docker-compose.yml up -d`
- **THEN** 啟動的服務不包含 `phpmyadmin`port 8081)及 `mailpit`port 8025
### Requirement: 開發工具服務透過 compose.override.yml 提供
`compose.override.yml` SHALL 定義 `phpmyadmin``mailpit` service,供本機開發與 VPS 管理使用。
#### Scenario: 有 override 檔時開發工具正常啟動
- **WHEN** 專案根目錄存在 `compose.override.yml` 且執行 `docker compose up -d`(不指定 -f
- **THEN** Docker Compose 自動合併 override`phpmyadmin`port 8081)與 `mailpit`port 8025)一同啟動
#### Scenario: 雲端部署排除開發工具
- **WHEN** CI/CD 執行 `docker compose -f docker-compose.yml up -d`(明確指定單一檔案)
- **THEN** `phpmyadmin``mailpit` 不啟動,不佔用任何 port 或資源
@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: QUEUE_CONNECTION 預設值為 redis
`.env.example``QUEUE_CONNECTION` SHALL 預設為 `redis`,與 VPS 實際運作設定一致,避免新環境按範本初始化後跑在效能較差的 database queue。
#### Scenario: 新環境照 .env.example 初始化
- **WHEN** 開發者或 CI 以 `.env.example` 為基礎建立 `.env`,未手動修改 `QUEUE_CONNECTION`
- **THEN** Laravel queue driver 使用 Redisqueue worker 正常消費 Redis 佇列中的 job
### Requirement: .env.example 標示雲端必要變數
`.env.example` SHALL 以行內註解或分組說明標示以下雲端部署時必須明確設定的變數,協助操作者識別必填項目:
- `FILESYSTEM_DISK`:雲端應設為 `s3`(目前預設 `local`,雲端會導致檔案在容器重啟後遺失)
- `LOG_CHANNEL` / `LOG_STACK`:雲端建議改為 `stderr` 以接入雲端 logging aggregator
- `QUEUE_CONNECTION`:應為 `redis`
- `SESSION_DRIVER`:雲端建議改為 `redis`
#### Scenario: 操作者閱讀 .env.example 進行雲端部署
- **WHEN** 操作者參照 `.env.example` 設定雲端環境的 `.env`
- **THEN** 每個雲端關鍵變數旁有說明,提示預設值在雲端環境的限制與建議替代值
@@ -0,0 +1,31 @@
## 1. docker-compose.yml 調整
- [x] 1.1 [後端] 修改 `docker-compose.yml` app service healthcheck:將 `test: ["CMD", "php", "-v"]` 改為 `test: ["CMD-SHELL", "nc -z 127.0.0.1 9000 || exit 1"]`app container 無 HTTP server,用 nc 驗證 PHP-FPM FastCGI port 9000
- [x] 1.2 [後端] 從 `docker-compose.yml` 移除 `phpmyadmin` service 完整定義(包含 image、container_name、environment、networks、depends_on
- [x] 1.3 [後端] 從 `docker-compose.yml` 移除 `mailpit` service 完整定義
## 2. 新增 compose.override.yml
- [x] 2.1 [後端] 在專案根目錄新建 `compose.override.yml`,加入 `phpmyadmin` service(保留原有的 PMA_HOST、PMA_PORT、PMA_USER、PMA_PASSWORD、MYSQL_ROOT_PASSWORD 環境變數設定與 networks、depends_on
- [x] 2.2 [後端] 在 `compose.override.yml` 加入 `mailpit` service(保留原有設定與 networks
## 3. .env.example 更新
- [x] 3.1 [後端] 將 `.env.example``QUEUE_CONNECTION` 值從 `database` 改為 `redis`
- [x] 3.2 [後端] 在 `.env.example``FILESYSTEM_DISK=local` 旁加上行內說明,標示雲端應改為 `s3`
- [x] 3.3 [後端] 在 `.env.example``LOG_CHANNEL` / `LOG_STACK` 區段加上說明,標示雲端建議改為 `stderr`
- [x] 3.4 [後端] 在 `.env.example``SESSION_DRIVER=database` 旁加上說明,標示雲端建議改為 `redis`
- [x] 3.5 [後端] 在 `.env.example``QUEUE_CONNECTION=redis` 旁加上說明,確認此為雲端建議值
## 4. 文件更新
- [x] 4.1 [後端] 更新 `docs/STARTUP.md`,新增「部署模式」章節,明確列出兩組指令:
- **本機 / VPS**`docker compose up -d`(自動合併 override,含 phpmyadmin + mailpit
- **雲端正式環境**`docker compose -f docker-compose.yml up -d`(排除開發工具)
- 說明 `compose.override.yml` 已進版控,VPS `git pull` 後即可取得,不需手動建立
## 5. 手動驗證
- [x] 5.1 [整合測試] 在本機執行 `docker compose up -d`,確認 phpmyadmin8081)與 mailpit8025)仍可正常存取
- [x] 5.2 [整合測試] 執行 `docker compose -f docker-compose.yml up -d --remove-orphans`,確認 phpmyadmin 與 mailpit 不啟動
- [x] 5.3 [整合測試] 確認 `app` container 的 healthcheck 狀態在啟動後顯示 healthy`docker compose ps` 查看)
+8 -8
View File
@@ -57,13 +57,13 @@
---
### Requirement: 管理員驗證教練
後端 SHALL 提供 `PUT /api/admin/providers/{id}/toggle-verified`,反轉 ProviderProfile.is_verified 狀態
### Requirement: 管理員驗證教練(已由審核狀態機取代)
`PUT /api/admin/providers/{id}/toggle-verified` 已於 2026-06-12 移除——單鍵切換允許繞過審核狀態機(無原因駁回、未送審直接通過)。教練驗證狀態的變更一律透過 `PUT /api/admin/verifications/{userId}/approve|reject`(見 provider-verification 規格「Admin 審核佇列與裁決」)
#### Scenario: 驗證教練
- **WHEN** 管理員對 is_verified=false 的教練發送請求
- **THEN** 將 is_verified 設為 true,回傳 HTTP 200`{ status: true, message: "教練已驗證", data: { is_verified: true } }`
#### Scenario: toggle 端點回 404
- **WHEN** 管理員請求 `PUT /api/admin/providers/{id}/toggle-verified`
- **THEN** 回傳 HTTP 404
#### Scenario: 取消驗證教練
- **WHEN** 管理員對 is_verified=true 的教練發送請求
- **THEN** 將 is_verified 設為 false,回傳 HTTP 200`{ status: true, message: "已取消驗證", data: { is_verified: false } }`
#### Scenario: 教練列表仍顯示驗證狀態
- **WHEN** 管理員請求 `GET /api/admin/providers`
- **THEN** 每筆教練的 provider_profile 包含 `verification_status` 與相容用 `is_verified`accessor= approved
@@ -0,0 +1,36 @@
# compose-cloud-baseline
Docker Compose 雲端基準設定——healthcheck 使用真實端點、正式 compose 不含開發工具、dev-only 服務透過 override 隔離。
## Requirements
### Requirement: App service healthcheck 驗證 PHP-FPM FastCGI port
`docker-compose.yml``app` service healthcheck SHALL 使用 `nc -z 127.0.0.1 9000` 確認 PHP-FPM FastCGI port 正在監聽,而非執行 `php -v`(只驗證 binary 存在)。
注意:app container 內無 HTTP server,無法透過 HTTP 打 `/health``netcat-openbsd` 已在 Dockerfile 安裝。
#### Scenario: PHP-FPM 啟動後 healthcheck 通過
- **WHEN** `app` container 啟動且 PHP-FPM 程序已監聽 port 9000
- **THEN** `nc -z 127.0.0.1 9000` 回傳 exit code 0container 標記為 healthy
#### Scenario: PHP-FPM 未啟動時 healthcheck 失敗
- **WHEN** `app` container 啟動但 PHP-FPM 程序尚未就緒(port 9000 未開)
- **THEN** `nc -z 127.0.0.1 9000` 回傳非 0 exit codecontainer 維持 unhealthy 狀態直到重試成功
### Requirement: 正式 compose 不含開發工具服務
`docker-compose.yml` SHALL 不包含 `phpmyadmin``mailpit` service 定義。這兩個服務屬開發/管理工具,不得出現在正式雲端部署的 compose 設定中。
#### Scenario: 只使用 docker-compose.yml 啟動
- **WHEN** 執行 `docker compose -f docker-compose.yml up -d`
- **THEN** 啟動的服務不包含 `phpmyadmin`port 8081)及 `mailpit`port 8025
### Requirement: 開發工具服務透過 compose.override.yml 提供
`compose.override.yml` SHALL 定義 `phpmyadmin`port 8081)與 `mailpit`port 8025service,供本機開發與 VPS 管理使用。
#### Scenario: 有 override 檔時開發工具正常啟動
- **WHEN** 專案根目錄存在 `compose.override.yml` 且執行 `docker compose up -d`(不指定 -f
- **THEN** Docker Compose 自動合併 override`phpmyadmin`port 8081)與 `mailpit`port 8025)一同啟動
#### Scenario: 雲端部署排除開發工具
- **WHEN** CI/CD 執行 `docker compose -f docker-compose.yml up -d --remove-orphans`
- **THEN** `phpmyadmin``mailpit` 不啟動,不佔用任何 port 或資源
@@ -0,0 +1,31 @@
# env-cloud-annotations
`.env.example` 補充雲端部署必要環境變數的說明與正確預設值。
## Requirements
### Requirement: QUEUE_CONNECTION 預設值為 redis
`.env.example``QUEUE_CONNECTION` SHALL 預設為 `redis`,與 VPS 實際運作設定一致,避免新環境按範本初始化後跑在效能較差的 database queue。
#### Scenario: 新環境照 .env.example 初始化
- **WHEN** 開發者或 CI 以 `.env.example` 為基礎建立 `.env`,未手動修改 `QUEUE_CONNECTION`
- **THEN** Laravel queue driver 使用 Redisqueue worker 正常消費 Redis 佇列中的 job
### Requirement: .env.example 標示雲端必要變數
`.env.example` SHALL 以行內註解或正確預設值標示以下雲端部署時必須明確設定的變數:
- `FILESYSTEM_DISK`:雲端應設為 `s3`(預設 `local` 在容器重啟後遺失上傳檔案)
- `LOG_CHANNEL`:預設改為 `stderr`(雲端 logging aggregator 標準;原 `stack/daily` 寫檔案不適合雲端)
- `QUEUE_CONNECTION`:預設為 `redis`
- `SESSION_DRIVER`:預設改為 `redis`(取代 `database`,擴容更高效)
#### Scenario: 新環境照 .env.example 初始化 session driver
- **WHEN** 開發者以 `.env.example` 為基礎建立 `.env`,未手動修改 `SESSION_DRIVER`
- **THEN** Laravel session driver 使用 Redis
#### Scenario: 新環境照 .env.example 初始化 log channel
- **WHEN** 開發者以 `.env.example` 為基礎建立 `.env`,未手動修改 `LOG_CHANNEL`
- **THEN** Laravel 日誌寫入 stderr,可透過 `docker compose logs app` 查看
#### Scenario: 操作者閱讀 .env.example 進行雲端部署
- **WHEN** 操作者參照 `.env.example` 設定雲端環境的 `.env`
- **THEN** 每個雲端關鍵變數旁有說明,提示預設值在雲端環境的限制與建議替代值
@@ -0,0 +1,147 @@
# feature-test-coverage Specification
## Purpose
定義核心業務流程端點的測試覆蓋契約:Provider 課程 CRUD、時段管理、Admin 使用者操作、通知直接觸發路徑、預約列表端點。補齊 2026-06-11 稽核報告識別的 Feature test 缺口,確保這些端點的回歸能被測試套件即時攔截。
---
## Requirements
### Requirement: Provider 課程 CRUD 測試覆蓋
測試套件 SHALL 驗證 `POST /api/provider/offers``PUT /api/provider/offers/{id}``DELETE /api/provider/offers/{id}` 的正常流程與所有權邊界。
#### Scenario: Provider 建立課程成功
- **WHEN** 已認證的 Provider 送出有效的 title / location / price / region 至 `POST /api/provider/offers`
- **THEN** 回傳 HTTP 201body 包含 `{ status: true, data: { id, title, provider_id } }`DB 存在對應記錄且 `provider_id` 為當前 Provider
#### Scenario: 建立課程缺少必填欄位回傳 422
- **WHEN** Provider 送出缺少 `title` 的請求至 `POST /api/provider/offers`
- **THEN** 回傳 HTTP 422,body 包含對應欄位的驗證錯誤
#### Scenario: Provider 更新自己的課程
- **WHEN** 已認證的 Provider 送出有效更新欄位至 `PUT /api/provider/offers/{id}`(該課程屬於此 Provider
- **THEN** 回傳 HTTP 200,body 包含更新後的課程資料,DB 記錄已變更
#### Scenario: Provider 不可更新他人課程
- **WHEN** Provider A 嘗試 `PUT /api/provider/offers/{id}`,但該課程屬於 Provider B
- **THEN** 回傳 HTTP 403body 包含 `{ status: false }`
#### Scenario: Provider 刪除自己的課程
- **WHEN** 已認證的 Provider 送出 `DELETE /api/provider/offers/{id}`(該課程屬於此 Provider
- **THEN** 回傳 HTTP 200body 包含 `{ status: true }`DB 記錄已不存在
#### Scenario: Provider 不可刪除他人課程
- **WHEN** Provider A 嘗試 `DELETE /api/provider/offers/{id}`,但該課程屬於 Provider B
- **THEN** 回傳 HTTP 403
#### Scenario: 未認證請求被拒絕
- **WHEN** 未帶任何 token 送出 `POST /api/provider/offers`
- **THEN** 回傳 HTTP 401
---
### Requirement: Provider 時段管理測試覆蓋
測試套件 SHALL 驗證 `POST /api/provider/schedules``PUT /api/provider/schedules/{id}``DELETE /api/provider/schedules/{id}` 的正常流程、所有權邊界與容量驗證。
#### Scenario: Provider 建立時段成功
- **WHEN** Provider 送出有效的 diving_offer_id / scheduled_date(未來日期)/ start_time / max_participants 至 `POST /api/provider/schedules`
- **THEN** 回傳 HTTP 201body 包含 `{ status: true, data: { id, scheduled_date, status: "open" } }`
#### Scenario: Provider 不可為他人課程建立時段
- **WHEN** Provider A 送出 Provider B 課程的 diving_offer_id 至 `POST /api/provider/schedules`
- **THEN** 回傳 HTTP 403
#### Scenario: 時段日期不可為過去
- **WHEN** Provider 送出 scheduled_date 為過去日期
- **THEN** 回傳 HTTP 422
#### Scenario: Provider 更新時段人數上限
- **WHEN** Provider 送出新的 max_participants 至 `PUT /api/provider/schedules/{id}`(新值 ≥ 目前 current_participants
- **THEN** 回傳 HTTP 200DB 記錄已更新
#### Scenario: 人數上限不可低於已確認人數
- **WHEN** Provider 嘗試將 max_participants 設為低於時段 current_participants 的值
- **THEN** 回傳 HTTP 422,message 說明不可低於已確認人數
#### Scenario: Provider 不可更新他人時段
- **WHEN** Provider A 嘗試 `PUT /api/provider/schedules/{id}`,但該時段屬於 Provider B
- **THEN** 回傳 HTTP 403
#### Scenario: 刪除時段同時將進行中預約標記為 provider_cancelled
- **WHEN** Provider 對有 pending/confirmed 預約的時段送出 `DELETE /api/provider/schedules/{id}`
- **THEN** 回傳 HTTP 200;時段 status 改為 `cancelled`(記錄保留,不刪除);該時段的 pending/confirmed booking status 改為 `provider_cancelled`(記錄保留,不刪除);已是終態的 bookingcompleted/expired/已取消)不受影響
---
### Requirement: Admin 使用者管理測試覆蓋
測試套件 SHALL 驗證 Admin 查詢會員/教練列表、切換帳號啟用狀態、切換教練驗證狀態的正確行為。
#### Scenario: Admin 取得會員列表
- **WHEN** Admin 送出 `GET /api/admin/members`
- **THEN** 回傳 HTTP 200data 陣列只包含 role=member 的使用者
#### Scenario: Admin 取得教練列表
- **WHEN** Admin 送出 `GET /api/admin/providers`
- **THEN** 回傳 HTTP 200data 陣列只包含 role=provider 的使用者
#### Scenario: Admin 切換會員帳號啟用狀態
- **WHEN** Admin 送出 `PUT /api/admin/members/{id}/toggle-active`(目標會員 is_active=true
- **THEN** 回傳 HTTP 200DB 記錄 is_active 由 true 變為 false
#### Scenario: Admin 核准教練審核
- **WHEN** Admin 對 `verification_status=pending` 的教練送出 `PUT /api/admin/verifications/{userId}/approve`
- **THEN** 回傳 HTTP 200provider_profiles.verification_status 變為 `approved`
- **NOTE** `toggle-verified` 路由已由 provider-verification-workflow 移除,驗證狀態變更一律透過審核狀態機
#### Scenario: 非 Admin 角色被拒絕
- **WHEN** role=member 的使用者送出 `GET /api/admin/members`
- **THEN** 回傳 HTTP 403
---
### Requirement: 通知直接觸發路徑測試覆蓋
測試套件 SHALL 驗證預約 controller 操作直接觸發的通知事件,補足 Scheduler 路徑以外的覆蓋缺口。收件者依實際 controller 實作:新預約與 member 取消通知教練,其餘通知學員。
#### Scenario: 建立預約觸發 BookingCreatedNotification 至教練
- **WHEN** Member 成功建立預約(`POST /api/member/bookings`
- **THEN** `BookingCreatedNotification` 被派發至該預約所屬的 **Provider**
#### Scenario: Provider 確認預約觸發 BookingConfirmedNotification 至學員
- **WHEN** Provider 對 pending 預約呼叫 `PUT /api/provider/bookings/{id}/confirm`
- **THEN** `BookingConfirmedNotification` 被派發至該預約的 **Member**
#### Scenario: Provider 拒絕預約觸發 BookingRejectedNotification 至學員
- **WHEN** Provider 對 pending 預約呼叫 `PUT /api/provider/bookings/{id}/reject`
- **THEN** `BookingRejectedNotification` 被派發至該預約的 **Member**
#### Scenario: Member 取消預約觸發 BookingCancelledNotification 至教練
- **WHEN** Member 對 pending 預約呼叫 `DELETE /api/member/bookings/{id}`
- **THEN** `BookingCancelledNotification` 被派發至該預約所屬的 **Provider**
#### Scenario: Provider 取消預約觸發 BookingCancelledNotification 至學員
- **WHEN** Provider 對 confirmed 預約呼叫 `PUT /api/provider/bookings/{id}/cancel`
- **THEN** `BookingCancelledNotification` 被派發至該預約的 **Member**
---
### Requirement: 預約列表端點測試覆蓋
測試套件 SHALL 驗證下列三個路由的資料隔離行為:
- `GET /api/member/bookings`middleware: `auth:sanctum`,隔離靠 `where member_id = auth()->id()`
- `GET /api/provider/bookings`middleware: `auth:sanctum`,隔離靠 `whereHas schedule.provider_id`
- `GET /api/admin/bookings`middleware: `auth:sanctum` + `admin`EnsureAdmin 於 middleware 層強制 403
#### Scenario: Member 只能看到自己的預約
- **WHEN** Member A 呼叫 `GET /api/member/bookings`,系統中同時存在 Member A 與 Member B 各一筆預約
- **THEN** 回傳 data 只包含 Member A 的 booking idMember B 的 booking id 不在 data 中
#### Scenario: Provider 只能看到屬於自己課程的預約
- **WHEN** Provider A 呼叫 `GET /api/provider/bookings`,系統中有屬於 Provider A 與 Provider B 課程各一筆預約
- **THEN** 回傳 data 只包含 Provider A 課程的 booking idProvider B 課程的 booking id 不在 data 中
#### Scenario: Admin 可查看所有預約
- **WHEN** Admin 呼叫 `GET /api/admin/bookings`,系統中有 2 筆不同 Member 的預約
- **THEN** 回傳 data 包含兩筆 booking id
#### Scenario: 未認證請求被拒絕
- **WHEN** 未帶 token 呼叫 `GET /api/member/bookings`
- **THEN** 回傳 HTTP 401
+92 -50
View File
@@ -2,77 +2,119 @@
## Purpose
定義 `provider_profiles.is_verified` 的最小業務語意:未通過平台驗證的教練,其課程不對公開端點曝光。在完整教練審核流程(證照上傳、審核佇列、駁回原因)實作前,先以此約束堵住「未審核教練可公開曝光」的風險
定義教練資質審核的完整生命週期:教練上傳證照送審、Admin 審核裁決(通過/駁回原因)、結果通知,以及「未通過審核(approved)之教練的課程不對公開端點曝光、不可接受新預約」的可見性約束
## 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
### Requirement: 教練驗證狀態機
`provider_profiles.verification_status` SHALL 為四狀態字串:`unsubmitted`(註冊預設)、`pending``approved``rejected`。合法轉移僅限:`unsubmitted→pending`(送審)、`pending→approved`(通過)、`pending→rejected`(駁回)、`rejected→pending`(重新送審,同時清空 `rejection_reason`)、`approved→rejected`(撤銷,原因必填)。原 boolean `is_verified` 欄位移除,API 輸出以 accessor 保留 `is_verified`= status 為 approved
#### Scenario: 已驗證教練的課程正常曝光
- **WHEN** 匿名使用者請求 `GET /api/diving-offers`
- **THEN** 已驗證教練(is_verified=true)的課程出現在結果中
#### Scenario: 新教練註冊預設未送審
- **WHEN** 教練完成註冊
- **THEN** `verification_status = unsubmitted`,不出現在 Admin 待審佇列
#### Scenario: 未驗證教練的課程從列表排除
- **WHEN** 匿名使用者請求 `GET /api/diving-offers`
- **THEN** 未驗證教練(is_verified=false 或無 ProviderProfile)的課程不出現在結果中
#### Scenario: 非法轉移被拒絕
- **WHEN** 嘗試對 `unsubmitted` 教練執行 approve,或對 `approved` 教練執行 approve
- **THEN** 回傳 HTTP 422,狀態不變
#### Scenario: 未驗證教練的課程詳情回 404
- **WHEN** 匿名使用者請求 `GET /api/diving-offers/{id}`,該課程屬於未驗證教練
---
### Requirement: 教練上傳證照與送審
教練 SHALL 能於後台上傳 1~3 張證照圖片(jpeg/png/webp、≤10MB,伺服器端壓縮為 `.jpg`,存 `providers/{user_id}/certifications/`)並送出審核。證照僅於 `unsubmitted` / `rejected` 狀態可增刪;`pending` / `approved` 狀態鎖定(審核依據不可變動)。送審需至少 1 張證照。
#### Scenario: 上傳證照
- **WHEN** `unsubmitted` 教練 `POST /api/provider/verification/certifications` 上傳合法圖片且現有 < 3 張
- **THEN** 壓縮儲存並建立 `provider_certifications` 紀錄,回傳圖片資訊
#### Scenario: 超過 3 張上限
- **WHEN** 教練已有 3 張證照再上傳
- **THEN** 回傳 HTTP 422
#### Scenario: 送審成功
- **WHEN** 教練至少有 1 張證照,`POST /api/provider/verification/submit`
- **THEN** 狀態轉為 `pending``rejection_reason` 清空
#### Scenario: 無證照不可送審
- **WHEN** 教練無任何證照即送審
- **THEN** 回傳 HTTP 422,狀態不變
#### Scenario: pending 期間證照鎖定
- **WHEN** `pending``approved` 教練嘗試上傳或刪除證照
- **THEN** 回傳 HTTP 422
#### Scenario: 查詢自身驗證狀態
- **WHEN** 教練 `GET /api/provider/verification`
- **THEN** 回傳 `status``rejection_reason``certifications[]`id、url
---
### Requirement: Admin 審核佇列與裁決
Admin SHALL 能查詢審核佇列(`GET /api/admin/verifications?status=pending`,預設 pending,含教練資料與證照 URL),並對 `pending` 教練執行通過(`PUT /api/admin/verifications/{userId}/approve`)或駁回(`PUT /api/admin/verifications/{userId}/reject``reason` 必填、max 500)。Admin SHALL 能駁回 `approved` 教練(撤銷驗證,原因必填)。裁決後 SHALL flush `diving_offers` 快取(可見性立即生效)。原 `PUT /api/admin/providers/{id}/toggle-verified` 端點已移除。
#### Scenario: 通過審核
- **WHEN** Admin 對 pending 教練執行 approve
- **THEN** 狀態轉 `approved`,教練課程立即恢復公開可見性(不受 180 秒快取影響)
#### Scenario: 駁回需附原因
- **WHEN** Admin 執行 reject 未帶 `reason`
- **THEN** 回傳 HTTP 422,狀態不變
#### Scenario: 撤銷已通過教練
- **WHEN** Admin 對 approved 教練執行 reject 並附原因
- **THEN** 狀態轉 `rejected`,課程立即從公開列表消失;既有預約照常
#### Scenario: 舊 toggle 端點已移除
- **WHEN** 任何人請求 `PUT /api/admin/providers/{id}/toggle-verified`
- **THEN** 回傳 HTTP 404
---
### Requirement: 審核結果通知
系統 SHALL 於 approve / reject 後通知該教練(站內 + Email):通過通知告知課程已可公開曝光;駁回通知包含駁回原因。通知失敗不阻斷審核主流程。
#### Scenario: 通過通知
- **WHEN** Admin approve
- **THEN** 教練收到站內通知與 Email,內容含審核通過訊息
#### Scenario: 駁回通知含原因
- **WHEN** Admin reject 並附原因
- **THEN** 教練收到的通知內容包含該原因
---
### Requirement: 未通過審核教練的課程不對公開端點曝光
公開課程端點(`GET /api/diving-offers` 列表、`GET /api/diving-offers/{id}` 詳情、`/{id}/schedules``/{id}/reviews`)SHALL 僅回傳符合以下任一條件的課程:(a) `provider_id` 為 null(平台自有資料);(b) 課程所屬教練 `verification_status = 'approved'`。其餘狀態(unsubmitted / pending / rejected,或無 ProviderProfile)的課程在列表中排除、詳情與子端點回傳 404。
#### Scenario: approved 教練的課程正常曝光
- **WHEN** 匿名使用者請求公開課程端點
- **THEN** approved 教練的課程出現在結果中,詳情/時段/評價端點照常回傳
#### Scenario: 非 approved 教練的課程不曝光
- **WHEN** 課程教練狀態為 unsubmitted / pending / rejected
- **THEN** 列表排除該課程,詳情/時段/評價端點回傳 404
#### Scenario: provider_id 為 null 的課程不受限
- **WHEN** 匿名使用者請求公開課程端點,課程的 `provider_id` 為 null
- **THEN** 課程正常曝光
---
### Requirement: 驗證狀態切換立即生效
管理員透過 `PUT /api/admin/providers/{id}/toggle-verified` 切換驗證狀態後,公開課程列表的快取(`diving_offers` cache tag)SHALL 立即失效,下次請求反映最新可見性
### Requirement: 未通過審核教練的課程不可建立新預約
`POST /api/member/bookings` SHALL 在建立預約前驗證 schedule 所屬課程可預約(`provider_id` 為 null 或教練 `verification_status = 'approved'`),不符時回傳 HTTP 422,不建立預約。既有預約(pending / confirmed / completedSHALL 不受教練驗證狀態變動影響:教練仍可處理 pending、confirmed 的聊天與完課流程照常、completed 可正常評價
#### Scenario: 取消驗證後課程立即從公開列表消失
- **WHEN** 管理員將教練 is_verified 由 true 切為 false
- **THEN** 下一次 `GET /api/diving-offers` 請求不包含該教練的課程(不受 180 秒快取影響)
---
### Requirement: 公開子端點套用相同可見性
課程的公開子端點(`GET /api/diving-offers/{id}/schedules``GET /api/diving-offers/{id}/reviews`)SHALL 套用與課程詳情相同的可見性規則:課程屬未驗證教練時回傳 HTTP 404。
#### Scenario: 隱藏課程的時段查詢回 404
- **WHEN** 匿名使用者請求 `GET /api/diving-offers/{id}/schedules`,該課程屬未驗證教練
- **THEN** 回傳 HTTP 404
#### Scenario: 隱藏課程的評價查詢回 404
- **WHEN** 匿名使用者請求 `GET /api/diving-offers/{id}/reviews`,該課程屬未驗證教練
- **THEN** 回傳 HTTP 404
#### Scenario: 可見課程的子端點正常
- **WHEN** 課程屬已驗證教練或 `provider_id` 為 null
- **THEN** 時段與評價端點照常回傳資料
---
### Requirement: 未驗證教練的課程不可建立新預約
`POST /api/member/bookings` SHALL 在建立預約前驗證 schedule 所屬課程可預約(`provider_id` 為 null 或教練 `is_verified = true`),不符時回傳 HTTP 422,不建立預約。既有預約(pending / confirmed / completedSHALL 不受教練驗證狀態變動影響:教練仍可處理 pending、confirmed 的聊天與完課流程照常、completed 可正常評價。
#### Scenario: 未驗證教練課程的新預約被拒絕
- **WHEN** 會員以未驗證教練課程的 schedule_id 送出預約
#### Scenario: 未通過審核教練課程的新預約被拒絕
- **WHEN** 會員以非 approved 教練課程的 schedule_id 送出預約
- **THEN** 回傳 HTTP 422`{ status: false, message: "此課程目前不開放預約" }`,不建立 Booking
#### Scenario: 教練被取消驗證後既有預約照常
- **WHEN** 教練在預約 confirmed 之後被取消驗證
#### Scenario: 教練被撤銷驗證後既有預約照常
- **WHEN** 教練在預約 confirmed 之後被撤銷驗證(approved→rejected
- **THEN** 該預約的聊天、完課、評價流程照常運作;僅新預約被擋
---
### Requirement: 教練自有管理端點不受可見性限制
Provider 對自己課程的管理端點(`/api/provider/offers*`)與 Admin 管理端點(`/api/admin/offers*`)SHALL 不受公開可見性過濾影響,未驗證教練仍可登入、編輯與管理自己的課程。
Provider 對自己課程的管理端點(`/api/provider/offers*`)與 Admin 管理端點(`/api/admin/offers*`)SHALL 不受公開可見性過濾影響,未通過審核的教練仍可登入、編輯與管理自己的課程、上傳證照與送審
#### Scenario: 未驗證教練仍可管理自己的課程
- **WHEN**驗證教練以有效 token 請求 `GET /api/provider/offers`
#### Scenario: 未通過審核教練仍可管理自己的課程
- **WHEN**通過審核教練以有效 token 請求 `GET /api/provider/offers`
- **THEN** 回傳該教練的全部課程
## Notes
完整審核流程(verification_status enum、證照上傳、審核佇列)見 `docs/analysis/2026-06-11-future-roadmap-feasibility.md` §2.1。
@@ -0,0 +1,23 @@
# scheduler-container
Laravel Scheduler 以獨立 container 執行,取代 app container 內的 cron daemon。
## Requirements
### Requirement: Scheduler 以獨立 container 執行
`docker-compose.yml` SHALL 定義 `scheduler` service,使用 `cfdive-platform` image,以 `php artisan schedule:work` 前台輪詢方式執行 Laravel Scheduler,取代原本在 app container 內的 cron daemon。
#### Scenario: Scheduler container 隨 app 啟動
- **WHEN** 執行 `docker compose up -d``app` container 已 healthy
- **THEN** `scheduler` container 啟動並持續執行 `php artisan schedule:work`
#### Scenario: App container 重啟時 scheduler 等待就緒
- **WHEN** `app` container 重啟中(unhealthy
- **THEN** `scheduler` container 依 `depends_on` 等待 `app` healthy 後才啟動,避免 scheduler 在 PHP-FPM 未就緒時執行任務
### Requirement: App container 不再包含 cron daemon
`Dockerfile` SHALL 不安裝 `cron` 套件,`docker-entrypoint.sh` SHALL 不執行 `service cron start`,確保 app container 職責單一(只跑 PHP-FPM)。
#### Scenario: App container 啟動不啟動 cron
- **WHEN** `app` container 啟動並執行 entrypoint
- **THEN** 不存在 cron daemon 程序,`pgrep cron` 回傳 exit code 1
+6 -3
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
bootstrap="tests/bootstrap.php"
colors="true"
>
<testsuites>
@@ -22,8 +22,11 @@
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<!-- forcedocker compose 將 DB_CONNECTION=mysql 設為真實環境變數,
phpunit 的 env 預設不覆蓋真實環境變數,導致容器內跑測試時
RefreshDatabase 直接清空開發用 MySQL2026-06-12 實際發生兩次) -->
<env name="DB_CONNECTION" value="sqlite" force="true"/>
<env name="DB_DATABASE" value=":memory:" force="true"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
+9 -1
View File
@@ -84,6 +84,11 @@ Route::middleware(['auth:sanctum'])->prefix('provider')->group(function () {
Route::get('/offers/{id}', [ProviderOfferController::class, 'show']);
Route::put('/offers/{id}', [ProviderOfferController::class, 'update']);
Route::delete('/offers/{id}', [ProviderOfferController::class, 'destroy']);
// 驗證申請(證照送審)
Route::get('/verification', [\App\Http\Controllers\API\ProviderVerificationController::class, 'show']);
Route::post('/verification/certifications', [\App\Http\Controllers\API\ProviderVerificationController::class, 'uploadCertification']);
Route::delete('/verification/certifications/{id}', [\App\Http\Controllers\API\ProviderVerificationController::class, 'deleteCertification']);
Route::post('/verification/submit', [\App\Http\Controllers\API\ProviderVerificationController::class, 'submit']);
// 課程圖片
Route::post('/offers/{id}/cover', [CourseImageController::class, 'uploadCover']);
Route::delete('/offers/{id}/cover', [CourseImageController::class, 'deleteCover']);
@@ -130,7 +135,10 @@ Route::middleware(['auth:sanctum', 'admin'])->prefix('admin')->group(function ()
Route::get('/providers', [AdminUserController::class, 'providers']);
Route::get('/providers/{id}', [AdminUserController::class, 'provider']);
Route::put('/providers/{id}/toggle-active', [AdminUserController::class, 'toggleProviderActive']);
Route::put('/providers/{id}/toggle-verified', [AdminUserController::class, 'toggleProviderVerified']);
// 教練審核(toggle-verified 已由審核狀態機取代)
Route::get('/verifications', [\App\Http\Controllers\API\AdminVerificationController::class, 'index']);
Route::put('/verifications/{userId}/approve', [\App\Http\Controllers\API\AdminVerificationController::class, 'approve']);
Route::put('/verifications/{userId}/reject', [\App\Http\Controllers\API\AdminVerificationController::class, 'reject']);
// 課程管理
Route::get('/offers', [AdminOfferController::class, 'index']);
Route::delete('/offers/{id}', [AdminOfferController::class, 'destroy']);
+204 -146
View File
@@ -612,28 +612,29 @@
]
}
},
"/admin/providers/{id}/toggle-verified": {
"put": {
"/admin/verifications": {
"get": {
"tags": [
"Admin 教練管理"
],
"summary": "切換教練審核狀態",
"description": "通過或撤銷教練審核,回傳新的 is_verified 狀態",
"operationId": "toggleProviderVerified",
"summary": "教練審核佇列",
"description": "查詢教練驗證申請(預設僅 pending;status=all 可查全部),含證照圖片 URL",
"operationId": "adminVerificationIndex",
"parameters": [
{
"name": "id",
"in": "path",
"description": "使用者 ID",
"required": true,
"name": "status",
"in": "query",
"description": "unsubmitted / pending / approved / rejected / all",
"required": false,
"schema": {
"type": "integer"
"type": "string",
"default": "pending"
}
}
],
"responses": {
"200": {
"description": "切換成功",
"description": "查詢成功",
"content": {
"application/json": {
"schema": {
@@ -642,13 +643,54 @@
"type": "boolean",
"example": true
},
"message": {
"type": "string",
"example": "教練已通過審核"
},
"is_verified": {
"type": "boolean",
"example": true
"data": {
"type": "array",
"items": {
"properties": {
"user_id": {
"type": "integer",
"example": 5
},
"name": {
"type": "string",
"example": "王教練"
},
"email": {
"type": "string",
"example": "coach@example.com"
},
"business_name": {
"type": "string",
"example": "藍海潛水"
},
"verification_status": {
"type": "string",
"example": "pending"
},
"rejection_reason": {
"type": "string",
"example": null,
"nullable": true
},
"certifications": {
"type": "array",
"items": {
"properties": {
"id": {
"type": "integer",
"example": 1
},
"url": {
"type": "string",
"example": "http://localhost:8080/storage/providers/5/certifications/uuid.jpg"
}
},
"type": "object"
}
}
},
"type": "object"
}
}
},
"type": "object"
@@ -656,6 +698,47 @@
}
}
},
"403": {
"description": "非 admin 角色",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorResponse"
}
}
}
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/admin/verifications/{userId}/approve": {
"put": {
"tags": [
"Admin 教練管理"
],
"summary": "通過教練審核",
"description": "將 pending 教練轉為 approved,課程恢復公開曝光並通知教練",
"operationId": "adminVerificationApprove",
"parameters": [
{
"name": "userId",
"in": "path",
"description": "教練使用者 ID",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "已通過審核"
},
"403": {
"description": "非 admin 角色",
"content": {
@@ -675,6 +758,97 @@
}
}
}
},
"422": {
"description": "當前狀態無法通過審核",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorResponse"
}
}
}
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/admin/verifications/{userId}/reject": {
"put": {
"tags": [
"Admin 教練管理"
],
"summary": "駁回教練審核",
"description": "駁回 pending 教練或撤銷 approved 教練,原因必填並通知教練",
"operationId": "adminVerificationReject",
"parameters": [
{
"name": "userId",
"in": "path",
"description": "教練使用者 ID",
"required": true,
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"required": [
"reason"
],
"properties": {
"reason": {
"type": "string",
"maxLength": 500,
"example": "證照影像不清晰,請重新拍攝上傳"
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "已駁回"
},
"403": {
"description": "非 admin 角色",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorResponse"
}
}
}
},
"404": {
"description": "教練不存在",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorResponse"
}
}
}
},
"422": {
"description": "原因未填或狀態不可駁回",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorResponse"
}
}
}
}
},
"security": [
@@ -3021,133 +3195,6 @@
]
}
},
"/admin/register": {
"post": {
"tags": [
"管理員"
],
"summary": "管理員註冊",
"description": "建立新的管理員帳號",
"operationId": "registerAdmin",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"required": [
"name",
"email",
"password",
"password_confirmation"
],
"properties": {
"name": {
"description": "使用者姓名",
"type": "string",
"example": "張管理"
},
"email": {
"description": "電子郵件",
"type": "string",
"format": "email",
"example": "admin@example.com"
},
"password": {
"description": "密碼",
"type": "string",
"format": "password",
"example": "password123"
},
"password_confirmation": {
"description": "確認密碼",
"type": "string",
"format": "password",
"example": "password123"
},
"phone": {
"description": "電話號碼",
"type": "string",
"example": "0912345678"
},
"position": {
"description": "職位",
"type": "string",
"example": "系統管理員"
},
"department": {
"description": "部門",
"type": "string",
"example": "IT部門"
}
},
"type": "object"
}
}
}
},
"responses": {
"201": {
"description": "管理員註冊成功",
"content": {
"application/json": {
"schema": {
"properties": {
"status": {
"type": "boolean",
"example": true
},
"message": {
"type": "string",
"example": "管理員註冊成功"
},
"data": {
"properties": {
"user": {
"$ref": "#/components/schemas/User"
},
"token": {
"type": "string",
"example": "1|abcdef1234567890"
},
"token_type": {
"type": "string",
"example": "Bearer"
}
},
"type": "object"
}
},
"type": "object"
}
}
}
},
"422": {
"description": "驗證失敗",
"content": {
"application/json": {
"schema": {
"properties": {
"status": {
"type": "boolean",
"example": false
},
"message": {
"type": "string",
"example": "驗證失敗"
},
"errors": {
"type": "object"
}
},
"type": "object"
}
}
}
}
}
}
},
"/admin/login": {
"post": {
"tags": [
@@ -6993,8 +7040,19 @@
"type": "string",
"example": "週一至週五 09:00-18:00,週六日 08:00-19:00"
},
"verification_status": {
"description": "審核狀態:unsubmitted / pending / approved / rejected",
"type": "string",
"example": "approved"
},
"rejection_reason": {
"description": "駁回原因(rejected 時有值)",
"type": "string",
"example": null,
"nullable": true
},
"is_verified": {
"description": "是否通過平台驗證",
"description": "是否通過平台驗證(相容欄位,= verification_status 為 approved",
"type": "boolean",
"example": true
},
+120
View File
@@ -0,0 +1,120 @@
<?php
namespace Tests\Feature;
use App\Models\MemberProfile;
use App\Models\ProviderProfile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Admin 使用者管理(admin-user-management 規格)。
*
* 列表端點必須依 role 過濾,否則 admin 介面會混入不同角色資料;
* toggle-verified 的快取清除副作用已由 DivingOfferVisibilityTest 覆蓋,
* 這裡只驗資料庫欄位變更與 HTTP 200
*/
class AdminUserManagementTest extends TestCase
{
use RefreshDatabase;
private function makeAdmin(): User
{
return User::factory()->create(['role' => 'admin']);
}
private function makeMember(): User
{
$member = User::factory()->create(['role' => 'member']);
MemberProfile::create(['user_id' => $member->id]);
return $member;
}
private function makeProvider(string $verificationStatus = 'unsubmitted'): User
{
$provider = User::factory()->create(['role' => 'provider']);
ProviderProfile::create(['user_id' => $provider->id, 'verification_status' => $verificationStatus]);
return $provider;
}
// ── 列表 ─────────────────────────────────────────────────
public function test_admin_lists_members_only(): void
{
$admin = $this->makeAdmin();
$member = $this->makeMember();
$provider = $this->makeProvider();
$response = $this->actingAs($admin)->getJson('/api/admin/members');
$response->assertOk()->assertJsonPath('status', true);
$ids = array_column($response->json('data'), 'id');
$this->assertContains($member->id, $ids);
$this->assertNotContains($provider->id, $ids);
}
public function test_admin_lists_providers_only(): void
{
$admin = $this->makeAdmin();
$member = $this->makeMember();
$provider = $this->makeProvider();
$response = $this->actingAs($admin)->getJson('/api/admin/providers');
$response->assertOk()->assertJsonPath('status', true);
$ids = array_column($response->json('data'), 'id');
$this->assertContains($provider->id, $ids);
$this->assertNotContains($member->id, $ids);
}
// ── toggle-active ────────────────────────────────────────
public function test_admin_toggles_member_active_status(): void
{
$admin = $this->makeAdmin();
// is_active 預設由 DB 決定,factory 不設值;直接測試 toggle 的翻轉效果
$member = User::factory()->create(['role' => 'member', 'is_active' => true]);
$this->actingAs($admin)
->putJson("/api/admin/members/{$member->id}/toggle-active")
->assertOk()
->assertJsonPath('data.is_active', false);
$this->assertDatabaseHas('users', [
'id' => $member->id,
'is_active' => false,
]);
}
// ── approve ─────────────────────────────────────────────
public function test_admin_approves_provider_verification(): void
{
$admin = $this->makeAdmin();
$provider = $this->makeProvider(verificationStatus: 'pending');
$this->actingAs($admin)
->putJson("/api/admin/verifications/{$provider->id}/approve")
->assertOk()
->assertJsonPath('status', true);
$this->assertDatabaseHas('provider_profiles', [
'user_id' => $provider->id,
'verification_status' => 'approved',
]);
}
// ── 角色保護 ─────────────────────────────────────────────
public function test_non_admin_is_forbidden(): void
{
$member = $this->makeMember();
$this->actingAs($member)
->getJson('/api/admin/members')
->assertStatus(403);
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace Tests\Feature;
use App\Models\ProviderProfile;
use App\Models\User;
use App\Notifications\ProviderVerificationApprovedNotification;
use App\Notifications\ProviderVerificationRejectedNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
/**
* Admin 審核裁決(provider-verification / admin-user-management 規格)。
*
* 審核是平台對教練資質的把關承諾:駁回必須有原因(教練的申訴與
* 補正依據)、裁決必須走狀態機(不可對未送審者直接通過)、
* toggle 後門必須保持關閉。
*/
class AdminVerificationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Notification::fake();
}
private function makeAdmin(): User
{
return User::factory()->create(['role' => 'admin']);
}
private function makeProvider(string $status): User
{
$provider = User::factory()->create(['role' => 'provider']);
ProviderProfile::create(['user_id' => $provider->id, 'verification_status' => $status]);
return $provider;
}
// ── 佇列 ─────────────────────────────────────────────────
public function test_queue_defaults_to_pending_only(): void
{
$pending = $this->makeProvider('pending');
$this->makeProvider('unsubmitted');
$this->makeProvider('approved');
$response = $this->actingAs($this->makeAdmin())->getJson('/api/admin/verifications');
$response->assertOk();
$ids = array_column($response->json('data'), 'user_id');
$this->assertSame([$pending->id], $ids);
}
public function test_queue_can_filter_all_statuses(): void
{
$this->makeProvider('pending');
$this->makeProvider('approved');
$response = $this->actingAs($this->makeAdmin())->getJson('/api/admin/verifications?status=all');
$this->assertCount(2, $response->json('data'));
}
// ── 裁決狀態機 ───────────────────────────────────────────
public function test_approve_pending_provider_and_notify(): void
{
$provider = $this->makeProvider('pending');
$this->actingAs($this->makeAdmin())
->putJson("/api/admin/verifications/{$provider->id}/approve")
->assertOk();
$this->assertSame('approved', $provider->providerProfile->fresh()->verification_status->value);
Notification::assertSentTo($provider, ProviderVerificationApprovedNotification::class);
}
public function test_reject_requires_reason(): void
{
$provider = $this->makeProvider('pending');
$this->actingAs($this->makeAdmin())
->putJson("/api/admin/verifications/{$provider->id}/reject")
->assertStatus(422);
$this->assertSame('pending', $provider->providerProfile->fresh()->verification_status->value);
}
public function test_reject_pending_provider_stores_reason_and_notifies(): void
{
$provider = $this->makeProvider('pending');
$this->actingAs($this->makeAdmin())
->putJson("/api/admin/verifications/{$provider->id}/reject", ['reason' => '證照已過期'])
->assertOk();
$profile = $provider->providerProfile->fresh();
$this->assertSame('rejected', $profile->verification_status->value);
$this->assertSame('證照已過期', $profile->rejection_reason);
Notification::assertSentTo($provider, ProviderVerificationRejectedNotification::class);
}
public function test_cannot_approve_unsubmitted_provider(): void
{
$provider = $this->makeProvider('unsubmitted');
$this->actingAs($this->makeAdmin())
->putJson("/api/admin/verifications/{$provider->id}/approve")
->assertStatus(422);
}
public function test_can_revoke_approved_provider_with_reason(): void
{
$provider = $this->makeProvider('approved');
$this->actingAs($this->makeAdmin())
->putJson("/api/admin/verifications/{$provider->id}/reject", ['reason' => '收到檢舉,資料不實'])
->assertOk();
$this->assertSame('rejected', $provider->providerProfile->fresh()->verification_status->value);
}
// ── 權限邊界與舊端點 ─────────────────────────────────────
public function test_non_admin_cannot_access_verification_endpoints(): void
{
$provider = $this->makeProvider('pending');
$member = User::factory()->create(['role' => 'member']);
$this->actingAs($member)->getJson('/api/admin/verifications')->assertStatus(403);
$this->actingAs($member)
->putJson("/api/admin/verifications/{$provider->id}/approve")
->assertStatus(403);
$this->actingAs($provider)
->putJson("/api/admin/verifications/{$provider->id}/approve")
->assertStatus(403);
}
public function test_legacy_toggle_verified_endpoint_is_removed(): void
{
$provider = $this->makeProvider('approved');
$this->actingAs($this->makeAdmin())
->putJson("/api/admin/providers/{$provider->id}/toggle-verified")
->assertStatus(404);
}
}
+3 -3
View File
@@ -44,7 +44,7 @@ class BookingLifecycleTest extends TestCase
ProviderProfile::create([
'user_id' => $provider->id,
'is_verified' => $isVerified,
'verification_status' => $isVerified ? 'approved' : 'unsubmitted',
]);
return $provider;
@@ -171,8 +171,8 @@ class BookingLifecycleTest extends TestCase
$member = $this->makeMember();
$booking = $this->makeBooking($member, $schedule, BookingStatus::Confirmed);
// 教練在預約成立後被取消驗證:只擋新預約,不毀既有合約
$provider->providerProfile->update(['is_verified' => false]);
// 教練在預約成立後被撤銷驗證(approved→rejected:只擋新預約,不毀既有合約
$provider->providerProfile->update(['verification_status' => 'rejected', 'rejection_reason' => '測試撤銷']);
$this->actingAs($member)
->getJson("/api/bookings/{$booking->id}/messages")
+131
View File
@@ -0,0 +1,131 @@
<?php
namespace Tests\Feature;
use App\Enums\BookingStatus;
use App\Enums\ScheduleStatus;
use App\Models\Booking;
use App\Models\CourseSchedule;
use App\Models\DivingOffer;
use App\Models\ProviderProfile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 預約列表端點資料隔離(booking-lifecycle 規格)。
*
* 路由對應(routes/api.php 已確認):
* GET /api/member/bookings MemberBookingController::indexauth:sanctum,無 role check
* GET /api/provider/bookings ProviderBookingController::indexauth:sanctum,無 role check
* GET /api/admin/bookings AdminBookingController::indexauth:sanctum + admin middleware
*
* 資料隔離靠 controller query 條件,不靠 middleware role 強制,
* 因此測試需分別以正確角色 actingAs 驗證隔離邊界。
*/
class BookingListTest extends TestCase
{
use RefreshDatabase;
private function makeProviderWithSchedule(): array
{
$provider = User::factory()->create(['role' => 'provider']);
ProviderProfile::create(['user_id' => $provider->id, 'verification_status' => 'approved']);
$offer = DivingOffer::create([
'provider_id' => $provider->id,
'title' => 'Dive Course',
'location' => 'Kenting',
'price' => 3000,
'region' => '南部',
'rating' => 0,
'reviews' => 0,
]);
$schedule = CourseSchedule::create([
'diving_offer_id' => $offer->id,
'provider_id' => $provider->id,
'scheduled_date' => now()->addDays(30)->toDateString(),
'start_time' => '09:00',
'max_participants' => 10,
'current_participants' => 0,
'status' => ScheduleStatus::Open,
]);
return [$provider, $schedule];
}
private function makePendingBooking(User $member, CourseSchedule $schedule): Booking
{
return Booking::create([
'schedule_id' => $schedule->id,
'member_id' => $member->id,
'participants' => 1,
'total_price' => 3000,
'status' => BookingStatus::Pending,
]);
}
// ── Member 列表 ──────────────────────────────────────────
public function test_member_sees_only_own_bookings(): void
{
[$provider, $schedule] = $this->makeProviderWithSchedule();
$memberA = User::factory()->create(['role' => 'member']);
$memberB = User::factory()->create(['role' => 'member']);
$bookingA = $this->makePendingBooking($memberA, $schedule);
$bookingB = $this->makePendingBooking($memberB, $schedule);
$response = $this->actingAs($memberA)->getJson('/api/member/bookings');
$response->assertOk();
$ids = array_column($response->json('data'), 'id');
$this->assertContains($bookingA->id, $ids);
$this->assertNotContains($bookingB->id, $ids);
}
// ── Provider 列表 ────────────────────────────────────────
public function test_provider_sees_only_own_course_bookings(): void
{
[$providerA, $scheduleA] = $this->makeProviderWithSchedule();
[$providerB, $scheduleB] = $this->makeProviderWithSchedule();
$member = User::factory()->create(['role' => 'member']);
$bookingA = $this->makePendingBooking($member, $scheduleA);
$bookingB = $this->makePendingBooking($member, $scheduleB);
$response = $this->actingAs($providerA)->getJson('/api/provider/bookings');
$response->assertOk();
$ids = array_column($response->json('data'), 'id');
$this->assertContains($bookingA->id, $ids);
$this->assertNotContains($bookingB->id, $ids);
}
// ── Admin 列表 ───────────────────────────────────────────
public function test_admin_sees_all_bookings(): void
{
$admin = User::factory()->create(['role' => 'admin']);
[$providerA, $scheduleA] = $this->makeProviderWithSchedule();
[$providerB, $scheduleB] = $this->makeProviderWithSchedule();
$memberA = User::factory()->create(['role' => 'member']);
$memberB = User::factory()->create(['role' => 'member']);
$bookingA = $this->makePendingBooking($memberA, $scheduleA);
$bookingB = $this->makePendingBooking($memberB, $scheduleB);
$response = $this->actingAs($admin)->getJson('/api/admin/bookings');
$response->assertOk();
$ids = array_column($response->json('data'), 'id');
$this->assertContains($bookingA->id, $ids);
$this->assertContains($bookingB->id, $ids);
}
// ── 未認證 ───────────────────────────────────────────────
public function test_unauthenticated_request_returns_401(): void
{
$this->getJson('/api/member/bookings')->assertStatus(401);
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ class BookingOversellTest extends TestCase
ProviderProfile::create([
'user_id' => $provider->id,
'is_verified' => true,
'verification_status' => 'approved',
]);
$offer = DivingOffer::create([
+5 -5
View File
@@ -24,7 +24,7 @@ class DivingOfferVisibilityTest extends TestCase
ProviderProfile::create([
'user_id' => $provider->id,
'is_verified' => $isVerified,
'verification_status' => $isVerified ? 'approved' : 'unsubmitted',
]);
return $provider;
@@ -127,7 +127,7 @@ class DivingOfferVisibilityTest extends TestCase
// ── 驗證狀態切換立即生效(快取失效) ─────────────────────
public function test_toggle_verified_takes_effect_immediately_despite_cache(): void
public function test_revoking_verification_takes_effect_immediately_despite_cache(): void
{
$provider = $this->makeProvider(true);
$this->makeOffer($provider->id, 'Toggle Course');
@@ -139,10 +139,10 @@ class DivingOfferVisibilityTest extends TestCase
array_column($this->getJson('/api/diving-offers')->json('data'), 'title')
);
// 撤銷驗證(approved→rejected)後快取必須立即失效
$this->actingAs($admin)
->putJson("/api/admin/providers/{$provider->id}/toggle-verified")
->assertOk()
->assertJsonPath('data.is_verified', false);
->putJson("/api/admin/verifications/{$provider->id}/reject", ['reason' => '資料不實'])
->assertOk();
$this->assertNotContains(
'Toggle Course',
+167
View File
@@ -0,0 +1,167 @@
<?php
namespace Tests\Feature;
use App\Enums\BookingStatus;
use App\Enums\ScheduleStatus;
use App\Models\Booking;
use App\Models\CourseSchedule;
use App\Models\DivingOffer;
use App\Models\ProviderProfile;
use App\Models\User;
use App\Notifications\BookingCancelledNotification;
use App\Notifications\BookingConfirmedNotification;
use App\Notifications\BookingCreatedNotification;
use App\Notifications\BookingRejectedNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
/**
* 預約通知直接觸發路徑(notification-triggers 規格)。
*
* 補齊 BookingSchedulerTest 以外的 controller 直接觸發路徑,
* 確保收件者正確:新預約與 member 取消通知教練,其餘通知學員。
*
* 收件者依 controller 原始碼確認:
* - BookingCreated $provider->notify()
* - BookingConfirmed $booking->member->notify()
* - BookingRejected $booking->member->notify()
* - BookingCancelled (member) $provider->notify()
* - BookingCancelled (provider) $booking->member->notify()
*/
class NotificationTriggerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Notification::fake();
}
private function makeProvider(): User
{
$provider = User::factory()->create(['role' => 'provider']);
ProviderProfile::create(['user_id' => $provider->id, 'verification_status' => 'approved']);
return $provider;
}
private function makeSchedule(User $provider): CourseSchedule
{
$offer = DivingOffer::create([
'provider_id' => $provider->id,
'title' => 'Dive Course',
'location' => 'Kenting',
'price' => 3000,
'region' => '南部',
'rating' => 0,
'reviews' => 0,
]);
return CourseSchedule::create([
'diving_offer_id' => $offer->id,
'provider_id' => $provider->id,
'scheduled_date' => now()->addDays(60)->toDateString(),
'start_time' => '09:00',
'max_participants' => 10,
'current_participants' => 0,
'status' => ScheduleStatus::Open,
]);
}
private function makePendingBooking(User $member, CourseSchedule $schedule): Booking
{
return Booking::create([
'schedule_id' => $schedule->id,
'member_id' => $member->id,
'participants' => 1,
'total_price' => 3000,
'status' => BookingStatus::Pending,
]);
}
// ── 建立預約 → 通知教練 ──────────────────────────────────
public function test_booking_created_notifies_provider(): void
{
$provider = $this->makeProvider();
$schedule = $this->makeSchedule($provider);
$member = User::factory()->create(['role' => 'member']);
$this->actingAs($member)->postJson('/api/member/bookings', [
'schedule_id' => $schedule->id,
'participants' => 1,
])->assertStatus(201);
Notification::assertSentTo($provider, BookingCreatedNotification::class);
}
// ── 確認預約 → 通知學員 ──────────────────────────────────
public function test_booking_confirmed_notifies_member(): void
{
$provider = $this->makeProvider();
$schedule = $this->makeSchedule($provider);
$member = User::factory()->create(['role' => 'member']);
$booking = $this->makePendingBooking($member, $schedule);
$this->actingAs($provider)
->putJson("/api/provider/bookings/{$booking->id}/confirm")
->assertOk();
Notification::assertSentTo($member, BookingConfirmedNotification::class);
}
// ── 拒絕預約 → 通知學員 ──────────────────────────────────
public function test_booking_rejected_notifies_member(): void
{
$provider = $this->makeProvider();
$schedule = $this->makeSchedule($provider);
$member = User::factory()->create(['role' => 'member']);
$booking = $this->makePendingBooking($member, $schedule);
$this->actingAs($provider)
->putJson("/api/provider/bookings/{$booking->id}/reject")
->assertOk();
Notification::assertSentTo($member, BookingRejectedNotification::class);
}
// ── Member 取消 → 通知教練 ───────────────────────────────
public function test_member_cancel_notifies_provider(): void
{
$provider = $this->makeProvider();
$schedule = $this->makeSchedule($provider);
$member = User::factory()->create(['role' => 'member']);
$booking = $this->makePendingBooking($member, $schedule);
$this->actingAs($member)
->deleteJson("/api/member/bookings/{$booking->id}")
->assertOk();
Notification::assertSentTo($provider, BookingCancelledNotification::class);
}
// ── Provider 取消 → 通知學員 ─────────────────────────────
public function test_provider_cancel_notifies_member(): void
{
$provider = $this->makeProvider();
$schedule = $this->makeSchedule($provider);
$member = User::factory()->create(['role' => 'member']);
$booking = $this->makePendingBooking($member, $schedule);
// 先確認,再取消(provider_cancelled 只能從 confirmed 轉換)
$booking->update(['status' => BookingStatus::Confirmed]);
$schedule->increment('current_participants', 1);
$this->actingAs($provider)
->putJson("/api/provider/bookings/{$booking->id}/cancel")
->assertOk();
Notification::assertSentTo($member, BookingCancelledNotification::class);
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
namespace Tests\Feature;
use App\Models\DivingOffer;
use App\Models\ProviderProfile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Provider 課程 CRUDcoach-offers-api 規格)。
*
* 所有權驗證是核心不變式:provider_id 決定哪位教練可操作,
* 未加檢查會讓任何已登入教練刪改他人課程。
*/
class ProviderOfferCrudTest extends TestCase
{
use RefreshDatabase;
private function makeProvider(): User
{
$provider = User::factory()->create(['role' => 'provider']);
ProviderProfile::create(['user_id' => $provider->id, 'verification_status' => 'approved']);
return $provider;
}
private function makeOffer(User $provider): DivingOffer
{
return DivingOffer::create([
'provider_id' => $provider->id,
'title' => 'Test Dive Course',
'location' => 'Kenting',
'price' => 3000,
'region' => '南部',
'rating' => 0,
'reviews' => 0,
]);
}
// ── store ────────────────────────────────────────────────
public function test_provider_creates_offer_successfully(): void
{
$provider = $this->makeProvider();
$response = $this->actingAs($provider)->postJson('/api/provider/offers', [
'title' => 'New Dive Course',
'location' => 'Kenting',
'price' => 5000,
'region' => '南部',
]);
$response->assertStatus(201)
->assertJsonPath('status', true)
->assertJsonPath('data.provider_id', $provider->id);
$this->assertDatabaseHas('diving_offers', [
'title' => 'New Dive Course',
'provider_id' => $provider->id,
]);
}
public function test_create_offer_missing_required_field_returns_422(): void
{
$provider = $this->makeProvider();
$this->actingAs($provider)->postJson('/api/provider/offers', [
// title is missing
'location' => 'Kenting',
'price' => 5000,
'region' => '南部',
])->assertStatus(422);
}
public function test_unauthenticated_request_is_rejected(): void
{
$this->postJson('/api/provider/offers', [
'title' => 'New Dive Course',
'location' => 'Kenting',
'price' => 5000,
'region' => '南部',
])->assertStatus(401);
}
// ── update ───────────────────────────────────────────────
public function test_provider_updates_own_offer(): void
{
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$this->actingAs($provider)->putJson("/api/provider/offers/{$offer->id}", [
'title' => 'Updated Title',
'price' => 9999,
])->assertOk()->assertJsonPath('status', true);
$this->assertDatabaseHas('diving_offers', [
'id' => $offer->id,
'title' => 'Updated Title',
'price' => 9999,
]);
}
public function test_provider_cannot_update_others_offer(): void
{
$ownerProvider = $this->makeProvider();
$otherProvider = $this->makeProvider();
$offer = $this->makeOffer($ownerProvider);
$this->actingAs($otherProvider)
->putJson("/api/provider/offers/{$offer->id}", ['title' => 'Hacked Title'])
->assertStatus(403);
}
// ── destroy ──────────────────────────────────────────────
public function test_provider_deletes_own_offer(): void
{
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$this->actingAs($provider)
->deleteJson("/api/provider/offers/{$offer->id}")
->assertOk()
->assertJsonPath('status', true);
$this->assertDatabaseMissing('diving_offers', ['id' => $offer->id]);
}
public function test_provider_cannot_delete_others_offer(): void
{
$ownerProvider = $this->makeProvider();
$otherProvider = $this->makeProvider();
$offer = $this->makeOffer($ownerProvider);
$this->actingAs($otherProvider)
->deleteJson("/api/provider/offers/{$offer->id}")
->assertStatus(403);
$this->assertDatabaseHas('diving_offers', ['id' => $offer->id]);
}
}
+202
View File
@@ -0,0 +1,202 @@
<?php
namespace Tests\Feature;
use App\Enums\BookingStatus;
use App\Enums\ScheduleStatus;
use App\Models\Booking;
use App\Models\CourseSchedule;
use App\Models\DivingOffer;
use App\Models\ProviderProfile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Provider 時段管理(course-scheduling 規格)。
*
* 容量不變式:max_participants 不可低於 current_participants
* 否則已確認名額會被靜默截斷;destroy 必須同步取消進行中預約,
* 不處理會讓 member 對不存在/取消的時段保有 pending/confirmed 狀態。
*/
class ProviderScheduleCrudTest extends TestCase
{
use RefreshDatabase;
private function makeProvider(): User
{
$provider = User::factory()->create(['role' => 'provider']);
ProviderProfile::create(['user_id' => $provider->id, 'verification_status' => 'approved']);
return $provider;
}
private function makeOffer(User $provider): DivingOffer
{
return DivingOffer::create([
'provider_id' => $provider->id,
'title' => 'Dive Course',
'location' => 'Kenting',
'price' => 3000,
'region' => '南部',
'rating' => 0,
'reviews' => 0,
]);
}
private function makeSchedule(DivingOffer $offer, int $currentParticipants = 0): CourseSchedule
{
return CourseSchedule::create([
'diving_offer_id' => $offer->id,
'provider_id' => $offer->provider_id,
'scheduled_date' => now()->addDays(30)->toDateString(),
'start_time' => '09:00',
'max_participants' => 10,
'current_participants' => $currentParticipants,
'status' => ScheduleStatus::Open,
]);
}
// ── store ────────────────────────────────────────────────
public function test_provider_creates_schedule_successfully(): void
{
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$response = $this->actingAs($provider)->postJson('/api/provider/schedules', [
'diving_offer_id' => $offer->id,
'scheduled_date' => now()->addDays(10)->toDateString(),
'start_time' => '09:00',
'max_participants' => 8,
]);
$response->assertStatus(201)
->assertJsonPath('status', true)
->assertJsonPath('data.status', 'open');
}
public function test_provider_cannot_create_schedule_for_others_offer(): void
{
$ownerProvider = $this->makeProvider();
$otherProvider = $this->makeProvider();
$offer = $this->makeOffer($ownerProvider);
$this->actingAs($otherProvider)->postJson('/api/provider/schedules', [
'diving_offer_id' => $offer->id,
'scheduled_date' => now()->addDays(10)->toDateString(),
'start_time' => '09:00',
'max_participants' => 8,
])->assertStatus(403);
}
public function test_past_date_returns_422(): void
{
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$this->actingAs($provider)->postJson('/api/provider/schedules', [
'diving_offer_id' => $offer->id,
'scheduled_date' => now()->subDay()->toDateString(),
'start_time' => '09:00',
'max_participants' => 8,
])->assertStatus(422);
}
// ── update ───────────────────────────────────────────────
public function test_provider_updates_schedule_max_participants(): void
{
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$schedule = $this->makeSchedule($offer);
$this->actingAs($provider)
->putJson("/api/provider/schedules/{$schedule->id}", ['max_participants' => 20])
->assertOk()
->assertJsonPath('data.max_participants', 20);
$this->assertDatabaseHas('course_schedules', [
'id' => $schedule->id,
'max_participants' => 20,
]);
}
public function test_max_participants_below_current_returns_422(): void
{
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$schedule = $this->makeSchedule($offer, currentParticipants: 3);
$this->actingAs($provider)
->putJson("/api/provider/schedules/{$schedule->id}", ['max_participants' => 2])
->assertStatus(422);
}
public function test_provider_cannot_update_others_schedule(): void
{
$ownerProvider = $this->makeProvider();
$otherProvider = $this->makeProvider();
$offer = $this->makeOffer($ownerProvider);
$schedule = $this->makeSchedule($offer);
$this->actingAs($otherProvider)
->putJson("/api/provider/schedules/{$schedule->id}", ['max_participants' => 20])
->assertStatus(403);
}
// ── destroy ──────────────────────────────────────────────
public function test_delete_schedule_marks_active_bookings_as_provider_cancelled(): void
{
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$schedule = $this->makeSchedule($offer);
$member = User::factory()->create(['role' => 'member']);
$pendingBooking = Booking::create([
'schedule_id' => $schedule->id,
'member_id' => $member->id,
'participants' => 1,
'total_price' => 3000,
'status' => BookingStatus::Pending,
]);
$confirmedBooking = Booking::create([
'schedule_id' => $schedule->id,
'member_id' => User::factory()->create(['role' => 'member'])->id,
'participants' => 1,
'total_price' => 3000,
'status' => BookingStatus::Confirmed,
]);
$completedBooking = Booking::create([
'schedule_id' => $schedule->id,
'member_id' => User::factory()->create(['role' => 'member'])->id,
'participants' => 1,
'total_price' => 3000,
'status' => BookingStatus::Completed,
]);
$this->actingAs($provider)
->deleteJson("/api/provider/schedules/{$schedule->id}")
->assertOk();
$this->assertDatabaseHas('course_schedules', [
'id' => $schedule->id,
'status' => ScheduleStatus::Cancelled->value,
]);
$this->assertDatabaseHas('bookings', [
'id' => $pendingBooking->id,
'status' => BookingStatus::ProviderCancelled->value,
]);
$this->assertDatabaseHas('bookings', [
'id' => $confirmedBooking->id,
'status' => BookingStatus::ProviderCancelled->value,
]);
// 終態 booking 不受影響
$this->assertDatabaseHas('bookings', [
'id' => $completedBooking->id,
'status' => BookingStatus::Completed->value,
]);
}
}
+163
View File
@@ -0,0 +1,163 @@
<?php
namespace Tests\Feature;
use App\Models\ProviderCertification;
use App\Models\ProviderProfile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* 教練端驗證申請流程(provider-verification 規格)。
*
* 證照是 Admin 的審核依據:pending/approved 期間若可變更,
* 等於審核通過後可抽換證照,狀態機與鎖定規則是審核公信力的基礎。
*/
class ProviderVerificationTest extends TestCase
{
use RefreshDatabase;
private function makeProvider(string $status = 'unsubmitted'): User
{
$provider = User::factory()->create(['role' => 'provider']);
ProviderProfile::create(['user_id' => $provider->id, 'verification_status' => $status]);
return $provider;
}
private function addCertification(User $provider): ProviderCertification
{
return ProviderCertification::create([
'user_id' => $provider->id,
'image_path' => "providers/{$provider->id}/certifications/test.jpg",
]);
}
// ── 查詢 ─────────────────────────────────────────────────
public function test_provider_can_view_own_verification_state(): void
{
$provider = $this->makeProvider('rejected');
$provider->providerProfile->update(['rejection_reason' => '證照不清晰']);
$this->addCertification($provider);
$this->actingAs($provider)->getJson('/api/provider/verification')
->assertOk()
->assertJsonPath('data.verification_status', 'rejected')
->assertJsonPath('data.rejection_reason', '證照不清晰')
->assertJsonCount(1, 'data.certifications');
}
// ── 證照上傳與鎖定 ───────────────────────────────────────
public function test_unsubmitted_provider_can_upload_certification(): void
{
Storage::fake('public');
$provider = $this->makeProvider();
$this->actingAs($provider)
->postJson('/api/provider/verification/certifications', [
'image' => UploadedFile::fake()->image('cert.jpg', 800, 600),
])->assertStatus(201);
$this->assertSame(1, $provider->providerCertifications()->count());
}
public function test_certification_limit_is_three(): void
{
Storage::fake('public');
$provider = $this->makeProvider();
foreach (range(1, 3) as $i) {
$this->addCertification($provider);
}
$this->actingAs($provider)
->postJson('/api/provider/verification/certifications', [
'image' => UploadedFile::fake()->image('cert4.jpg', 800, 600),
])->assertStatus(422);
}
public function test_certifications_are_locked_while_pending(): void
{
Storage::fake('public');
$provider = $this->makeProvider('pending');
$cert = $this->addCertification($provider);
$this->actingAs($provider)
->postJson('/api/provider/verification/certifications', [
'image' => UploadedFile::fake()->image('late.jpg', 800, 600),
])->assertStatus(422);
$this->actingAs($provider)
->deleteJson("/api/provider/verification/certifications/{$cert->id}")
->assertStatus(422);
}
public function test_provider_cannot_delete_others_certification(): void
{
$owner = $this->makeProvider();
$cert = $this->addCertification($owner);
$intruder = $this->makeProvider();
$this->actingAs($intruder)
->deleteJson("/api/provider/verification/certifications/{$cert->id}")
->assertStatus(403);
}
// ── 送審狀態機 ───────────────────────────────────────────
public function test_submit_requires_at_least_one_certification(): void
{
$provider = $this->makeProvider();
$this->actingAs($provider)
->postJson('/api/provider/verification/submit')
->assertStatus(422);
$this->assertSame('unsubmitted', $provider->providerProfile->fresh()->verification_status->value);
}
public function test_submit_moves_unsubmitted_to_pending(): void
{
$provider = $this->makeProvider();
$this->addCertification($provider);
$this->actingAs($provider)
->postJson('/api/provider/verification/submit')
->assertOk();
$this->assertSame('pending', $provider->providerProfile->fresh()->verification_status->value);
}
public function test_rejected_provider_can_resubmit_and_reason_is_cleared(): void
{
$provider = $this->makeProvider('rejected');
$provider->providerProfile->update(['rejection_reason' => '證照過期']);
$this->addCertification($provider);
$this->actingAs($provider)
->postJson('/api/provider/verification/submit')
->assertOk();
$profile = $provider->providerProfile->fresh();
$this->assertSame('pending', $profile->verification_status->value);
$this->assertNull($profile->rejection_reason);
}
public function test_pending_or_approved_provider_cannot_submit_again(): void
{
foreach (['pending', 'approved'] as $status) {
$provider = $this->makeProvider($status);
$this->addCertification($provider);
$this->actingAs($provider)
->postJson('/api/provider/verification/submit')
->assertStatus(422);
$this->assertSame($status, $provider->providerProfile->fresh()->verification_status->value);
}
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ class ReviewTest extends TestCase
{
$provider = User::factory()->create(['role' => 'provider']);
// 公開評價端點僅對已驗證教練的課程開放(provider-verification 規格)
ProviderProfile::create(['user_id' => $provider->id, 'is_verified' => true]);
ProviderProfile::create(['user_id' => $provider->id, 'verification_status' => 'approved']);
return DivingOffer::create([
'provider_id' => $provider->id,
'title' => '測試潛水課程',
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace Tests\Unit\Enums;
use App\Enums\BookingStatus;
use PHPUnit\Framework\TestCase;
/**
* BookingStatus enum 完整性測試。
* 確保 7 個狀態值與 booking-lifecycle 規格一致,
* 防止新增/刪除狀態時忘記同步 VALID_TRANSITIONS 常數。
*/
class BookingStatusTest extends TestCase
{
public function test_all_seven_cases_exist(): void
{
$values = array_map(fn ($case) => $case->value, BookingStatus::cases());
$this->assertEqualsCanonicalizing([
'pending',
'confirmed',
'completed',
'rejected',
'expired',
'member_cancelled',
'provider_cancelled',
], $values);
}
/** @dataProvider validStringProvider */
public function test_from_returns_correct_case(string $value, BookingStatus $expected): void
{
$this->assertSame($expected, BookingStatus::from($value));
}
public static function validStringProvider(): array
{
return [
['pending', BookingStatus::Pending],
['confirmed', BookingStatus::Confirmed],
['completed', BookingStatus::Completed],
['rejected', BookingStatus::Rejected],
['expired', BookingStatus::Expired],
['member_cancelled', BookingStatus::MemberCancelled],
['provider_cancelled', BookingStatus::ProviderCancelled],
];
}
public function test_try_from_returns_null_for_invalid_value(): void
{
$this->assertNull(BookingStatus::tryFrom('invalid_status'));
$this->assertNull(BookingStatus::tryFrom(''));
$this->assertNull(BookingStatus::tryFrom('PENDING'));
}
public function test_valid_transitions_covers_all_cases(): void
{
$allCases = array_map(fn ($case) => $case->value, BookingStatus::cases());
$transitionKeys = array_keys(\App\Models\Booking::VALID_TRANSITIONS);
$this->assertEqualsCanonicalizing(
$allCases,
$transitionKeys,
'VALID_TRANSITIONS 必須包含所有 BookingStatus 狀態,避免未知狀態繞過狀態機'
);
}
}
+155
View File
@@ -0,0 +1,155 @@
<?php
namespace Tests\Unit\Models;
use App\Enums\BookingStatus;
use App\Models\Booking;
use PHPUnit\Framework\TestCase;
/**
* Booking::canTransitionTo() 是純 PHP 邏輯(只讀 VALID_TRANSITIONS 常數),
* 不需要資料庫連線,用 PHPUnit 原生 TestCase 而非 Laravel TestCase。
*
* 規格依據:booking-lifecycle spec VALID_TRANSITIONS 表。
* 終態(completed/rejected/expired/member_cancelled/provider_cancelled)不可再轉換,
* 破壞此不變式會導致名額帳務錯誤與評價資格污染。
*/
class BookingTransitionTest extends TestCase
{
private function bookingWithStatus(BookingStatus $status): Booking
{
$booking = new Booking();
$booking->status = $status;
return $booking;
}
// ── pending 合法轉換 ─────────────────────────────────────
public function test_pending_can_transition_to_confirmed(): void
{
$this->assertTrue(
$this->bookingWithStatus(BookingStatus::Pending)
->canTransitionTo(BookingStatus::Confirmed)
);
}
public function test_pending_can_transition_to_rejected(): void
{
$this->assertTrue(
$this->bookingWithStatus(BookingStatus::Pending)
->canTransitionTo(BookingStatus::Rejected)
);
}
public function test_pending_can_transition_to_expired(): void
{
$this->assertTrue(
$this->bookingWithStatus(BookingStatus::Pending)
->canTransitionTo(BookingStatus::Expired)
);
}
public function test_pending_can_transition_to_member_cancelled(): void
{
$this->assertTrue(
$this->bookingWithStatus(BookingStatus::Pending)
->canTransitionTo(BookingStatus::MemberCancelled)
);
}
// ── pending 非法轉換 ─────────────────────────────────────
public function test_pending_cannot_transition_to_completed(): void
{
$this->assertFalse(
$this->bookingWithStatus(BookingStatus::Pending)
->canTransitionTo(BookingStatus::Completed)
);
}
public function test_pending_cannot_transition_to_provider_cancelled(): void
{
$this->assertFalse(
$this->bookingWithStatus(BookingStatus::Pending)
->canTransitionTo(BookingStatus::ProviderCancelled)
);
}
// ── confirmed 合法轉換 ───────────────────────────────────
public function test_confirmed_can_transition_to_completed(): void
{
$this->assertTrue(
$this->bookingWithStatus(BookingStatus::Confirmed)
->canTransitionTo(BookingStatus::Completed)
);
}
public function test_confirmed_can_transition_to_member_cancelled(): void
{
$this->assertTrue(
$this->bookingWithStatus(BookingStatus::Confirmed)
->canTransitionTo(BookingStatus::MemberCancelled)
);
}
public function test_confirmed_can_transition_to_provider_cancelled(): void
{
$this->assertTrue(
$this->bookingWithStatus(BookingStatus::Confirmed)
->canTransitionTo(BookingStatus::ProviderCancelled)
);
}
// ── confirmed 非法轉換 ───────────────────────────────────
public function test_confirmed_cannot_transition_to_pending(): void
{
$this->assertFalse(
$this->bookingWithStatus(BookingStatus::Confirmed)
->canTransitionTo(BookingStatus::Pending)
);
}
public function test_confirmed_cannot_transition_to_rejected(): void
{
$this->assertFalse(
$this->bookingWithStatus(BookingStatus::Confirmed)
->canTransitionTo(BookingStatus::Rejected)
);
}
public function test_confirmed_cannot_transition_to_expired(): void
{
$this->assertFalse(
$this->bookingWithStatus(BookingStatus::Confirmed)
->canTransitionTo(BookingStatus::Expired)
);
}
// ── 終態:不可再轉換 ─────────────────────────────────────
/** @dataProvider terminalStatuses */
public function test_terminal_status_cannot_transition_to_any_state(BookingStatus $terminal): void
{
$booking = $this->bookingWithStatus($terminal);
foreach (BookingStatus::cases() as $target) {
$this->assertFalse(
$booking->canTransitionTo($target),
"終態 {$terminal->value} 不應能轉換至 {$target->value}"
);
}
}
public static function terminalStatuses(): array
{
return [
'completed' => [BookingStatus::Completed],
'rejected' => [BookingStatus::Rejected],
'expired' => [BookingStatus::Expired],
'member_cancelled' => [BookingStatus::MemberCancelled],
'provider_cancelled' => [BookingStatus::ProviderCancelled],
];
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace Tests\Unit\Traits;
use App\Traits\NormalizesEmail;
use PHPUnit\Framework\TestCase;
/**
* NormalizesEmail::normalizeEmail() 是純字串邏輯。
* 帳號鎖定計數器(AuthLockoutTest)依賴此邏輯確保大小寫與空白
* 不會被視為不同帳號而繞過鎖定——這裡驗證邏輯本身,
* 而非驗證它被正確呼叫(那是 AuthLockoutTest 的職責)。
*/
class NormalizesEmailTest extends TestCase
{
private object $subject;
protected function setUp(): void
{
parent::setUp();
// normalizeEmail 是 private,透過匿名類別暴露供測試
$this->subject = new class {
use NormalizesEmail;
public function normalize(string $email): string
{
return $this->normalizeEmail($email);
}
};
}
public function test_uppercase_is_lowercased(): void
{
$this->assertSame('user@example.com', $this->subject->normalize('USER@EXAMPLE.COM'));
}
public function test_leading_and_trailing_whitespace_is_trimmed(): void
{
$this->assertSame('user@example.com', $this->subject->normalize(' user@example.com '));
}
public function test_mixed_case_and_whitespace_are_both_normalized(): void
{
$this->assertSame('user@example.com', $this->subject->normalize(' User@Example.COM '));
}
public function test_already_normalized_email_is_unchanged(): void
{
$this->assertSame('user@example.com', $this->subject->normalize('user@example.com'));
}
public function test_tab_whitespace_is_trimmed(): void
{
$this->assertSame('user@example.com', $this->subject->normalize("\tuser@example.com\t"));
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
// docker compose 將 DB_CONNECTION=mysql 注入為真實環境變數(存在於 $_SERVER),
// PHPUnit 的 <env force="true"> 只覆蓋 $_ENV / putenv,而 Laravel env() 透過
// phpdotenv 優先讀 $_SERVER——導致容器內跑測試時 RefreshDatabase 直接清空
// 開發用 MySQL2026-06-12 實際發生三次)。必須在 bootstrap 階段三者同步強制。
foreach (['DB_CONNECTION' => 'sqlite', 'DB_DATABASE' => ':memory:'] as $key => $value) {
$_SERVER[$key] = $value;
$_ENV[$key] = $value;
putenv("{$key}={$value}");
}
require __DIR__ . '/../vendor/autoload.php';