Compare commits
61 Commits
51784ecc64
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d1fe3d4138 | |||
| 0bffa909e2 | |||
| 9222bf17fc | |||
| b218fa0964 | |||
| a7e8468613 | |||
| 60ff56a571 | |||
| 4afe7b5fd5 | |||
| c3572682ad | |||
| 13b6b4145c | |||
| d423cf5079 | |||
| 55b2bff1b8 | |||
| e08c6652a5 | |||
| 2d14c058d1 | |||
| be629feef9 | |||
| 998f34139a | |||
| 0080e2da84 | |||
| 4720dc53f9 | |||
| 95a93b2d72 | |||
| c0616b17df | |||
| 6a74f766db | |||
| d8340de322 | |||
| aebce80152 | |||
| 5fdd5b3329 | |||
| daf72711e8 | |||
| 2fbcd0c50d | |||
| 979ea4df75 | |||
| 6b1e31c4af | |||
| 64874e1c12 | |||
| 19705fc8d7 | |||
| 80f5cf179f | |||
| d99ffb0fd6 | |||
| afdca18c67 | |||
| 4383239bab | |||
| 49a4b89655 | |||
| 66ff30f863 | |||
| d6cc1cbca9 | |||
| a18f52b2e8 | |||
| e40507bd60 | |||
| 43720482c4 | |||
| 37140554d3 | |||
| a7d5f81890 | |||
| c53e9657b3 | |||
| 6414222a18 | |||
| 87a4f70e65 | |||
| 99651b0bac | |||
| 07618fb1b3 | |||
| 5825e74d2d | |||
| 3384210c5c | |||
| 90d0a57e73 | |||
| c226275fc2 | |||
| 99cf35c1ec | |||
| 520ec487ee | |||
| a97a7d096c | |||
| 0dffca31bb | |||
| 7ca91da138 | |||
| cec2078035 | |||
| d3a0d300c6 | |||
| 63b25f96ac | |||
| 3c38d085bd | |||
| 88a81aac41 | |||
| aeb8c975e2 |
+22
-5
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
**會員(Member)**
|
||||
- 註冊 / 登入(Email + Google OAuth)
|
||||
- Token 自動續期(401 refresh-then-retry,sessionStorage 儲存)
|
||||
- 瀏覽、搜尋、篩選潛水課程
|
||||
- 查看課程時段並送出預約
|
||||
- 與教練即時訊息(文字 + 圖片,含已讀回執)
|
||||
@@ -15,14 +16,16 @@
|
||||
- 站內通知(Bell Icon 即時更新 + 瀏覽器推播)
|
||||
|
||||
**教練(Provider)**
|
||||
- 課程 CRUD(含封面 + 相簿圖片上傳)
|
||||
- 課程 CRUD(含封面 + 相簿圖片上傳,伺服器端壓縮)
|
||||
- 課程時段管理
|
||||
- 預約管理(確認 / 拒絕 / 完成 / 取消)
|
||||
- 與學員即時訊息
|
||||
- 證照上傳與教練資格送審
|
||||
|
||||
**管理員(Admin)**
|
||||
- 平台統計數據(會員數、教練數、課程數)
|
||||
- 會員與教練帳號管理(啟用 / 停用 / 審核)
|
||||
- 會員與教練帳號管理(啟用 / 停用)
|
||||
- 教練資格審核(送審 / 通過 / 駁回 / 撤銷)
|
||||
- 課程、預約、評價管理
|
||||
|
||||
---
|
||||
@@ -36,23 +39,29 @@
|
||||
| 資料庫 | MySQL 8.0 |
|
||||
| 快取 | Redis |
|
||||
| 即時通訊 | Laravel Reverb(WebSocket,`wss://ws.hank-space.com`)|
|
||||
| 認證 | Laravel Sanctum + Google OAuth |
|
||||
| 認證 | Laravel Sanctum + Google OAuth + Token Refresh |
|
||||
| 容器 | Docker / Docker Compose |
|
||||
| API 文件 | Swagger UI(l5-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 |
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AdminProfile;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class CreateAdminUser extends Command
|
||||
{
|
||||
protected $signature = 'app:create-admin
|
||||
{name : 管理員姓名}
|
||||
{email : 登入用電子郵件}
|
||||
{--password= : 密碼(至少 8 碼,未提供時互動式輸入)}
|
||||
{--position= : 職位}
|
||||
{--department= : 部門}';
|
||||
|
||||
protected $description = '建立管理員帳號(公開 /api/admin/register 端點已移除,管理員一律由主機端建立)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$password = $this->option('password') ?: $this->secret('請輸入密碼(至少 8 碼)');
|
||||
|
||||
$validator = Validator::make([
|
||||
'name' => $this->argument('name'),
|
||||
'email' => $this->argument('email'),
|
||||
'password' => $password,
|
||||
], [
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|string|email|max:255|unique:users',
|
||||
// 管理權限影響全平台,密碼門檻高於一般使用者的 min:6
|
||||
'password' => 'required|string|min:8',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
foreach ($validator->errors()->all() as $error) {
|
||||
$this->error($error);
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$user = User::create([
|
||||
'name' => $this->argument('name'),
|
||||
'email' => $this->argument('email'),
|
||||
'password' => Hash::make($password),
|
||||
'role' => 'admin',
|
||||
]);
|
||||
|
||||
AdminProfile::create([
|
||||
'user_id' => $user->id,
|
||||
'position' => $this->option('position'),
|
||||
'department' => $this->option('department'),
|
||||
]);
|
||||
|
||||
$this->info("管理員帳號已建立:{$user->email}(id={$user->id})");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
+68
-13
@@ -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()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
+97
-53
@@ -12,7 +12,7 @@ use OpenApi\Annotations as OA;
|
||||
* )
|
||||
*
|
||||
* @OA\Server(
|
||||
* url="/api",
|
||||
* url=L5_SWAGGER_CONST_HOST,
|
||||
* description="API 伺服器"
|
||||
* )
|
||||
*
|
||||
@@ -207,6 +207,38 @@ class AuthApiDoc
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 會員 Token 刷新
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/member/refresh",
|
||||
* summary="會員 Token 刷新",
|
||||
* description="撤銷當前 Token 並發放新的 Bearer token(不需要重新登入)",
|
||||
* operationId="refreshMember",
|
||||
* tags={"會員"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="刷新成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="token", type="string", example="24|newtoken..."),
|
||||
* @OA\Property(property="token_type", type="string", example="Bearer")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=401,
|
||||
* description="未認證",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="message", type="string", example="Unauthenticated.")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function refreshMember()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 會員登出
|
||||
*
|
||||
@@ -580,6 +612,38 @@ class AuthApiDoc
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 服務提供者 Token 刷新
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/provider/refresh",
|
||||
* summary="服務提供者 Token 刷新",
|
||||
* description="撤銷當前 Token 並發放新的 Bearer token",
|
||||
* operationId="refreshProvider",
|
||||
* tags={"服務提供者"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="刷新成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="token", type="string", example="25|newtoken..."),
|
||||
* @OA\Property(property="token_type", type="string", example="Bearer")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=401,
|
||||
* description="未認證",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="message", type="string", example="Unauthenticated.")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function refreshProvider()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 服務提供者登出
|
||||
*
|
||||
@@ -825,58 +889,6 @@ class AuthApiDoc
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理員註冊
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/admin/register",
|
||||
* summary="管理員註冊",
|
||||
* description="建立新的管理員帳號",
|
||||
* operationId="registerAdmin",
|
||||
* tags={"管理員"},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"name", "email", "password", "password_confirmation"},
|
||||
* @OA\Property(property="name", type="string", example="張管理", description="使用者姓名"),
|
||||
* @OA\Property(property="email", type="string", format="email", example="admin@example.com", description="電子郵件"),
|
||||
* @OA\Property(property="password", type="string", format="password", example="password123", description="密碼"),
|
||||
* @OA\Property(property="password_confirmation", type="string", format="password", example="password123", description="確認密碼"),
|
||||
* @OA\Property(property="phone", type="string", example="0912345678", description="電話號碼"),
|
||||
* @OA\Property(property="position", type="string", example="系統管理員", description="職位"),
|
||||
* @OA\Property(property="department", type="string", example="IT部門", description="部門")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=201,
|
||||
* description="管理員註冊成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="管理員註冊成功"),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="object",
|
||||
* @OA\Property(property="user", type="object", ref="#/components/schemas/User"),
|
||||
* @OA\Property(property="token", type="string", example="1|abcdef1234567890"),
|
||||
* @OA\Property(property="token_type", type="string", example="Bearer")
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=422,
|
||||
* description="驗證失敗",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=false),
|
||||
* @OA\Property(property="message", type="string", example="驗證失敗"),
|
||||
* @OA\Property(property="errors", type="object")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function registerAdmin()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理員登入
|
||||
*
|
||||
@@ -958,6 +970,38 @@ class AuthApiDoc
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理員 Token 刷新
|
||||
*
|
||||
* @OA\Post(
|
||||
* path="/admin/refresh",
|
||||
* summary="管理員 Token 刷新",
|
||||
* description="撤銷當前 Token 並發放新的 Bearer token",
|
||||
* operationId="refreshAdmin",
|
||||
* tags={"管理員"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="刷新成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="token", type="string", example="26|newtoken..."),
|
||||
* @OA\Property(property="token_type", type="string", example="Bearer")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=401,
|
||||
* description="未認證",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="message", type="string", example="Unauthenticated.")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function refreshAdmin()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理員登出
|
||||
*
|
||||
|
||||
@@ -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_id(string),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/png,message 與 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 共用)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum VerificationStatus: string
|
||||
{
|
||||
case Unsubmitted = 'unsubmitted';
|
||||
case Pending = 'pending';
|
||||
case Approved = 'approved';
|
||||
case Rejected = 'rejected';
|
||||
|
||||
public const VALID_TRANSITIONS = [
|
||||
'unsubmitted' => ['pending'],
|
||||
'pending' => ['approved', 'rejected'],
|
||||
'approved' => ['rejected'], // 撤銷驗證,原因必填
|
||||
'rejected' => ['pending'], // 重新送審
|
||||
];
|
||||
|
||||
public function canTransitionTo(self $newStatus): bool
|
||||
{
|
||||
return in_array($newStatus->value, self::VALID_TRANSITIONS[$this->value] ?? []);
|
||||
}
|
||||
}
|
||||
@@ -131,20 +131,5 @@ class AdminUserController extends Controller
|
||||
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
$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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -846,59 +846,6 @@ class AuthController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理員註冊
|
||||
*/
|
||||
public function registerAdmin(Request $request)
|
||||
{
|
||||
// 驗證請求數據
|
||||
$validator = Validator::make($request->all(), [
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|string|email|max:255|unique:users',
|
||||
'password' => 'required|string|min:6|confirmed',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'position' => 'nullable|string|max:100',
|
||||
'department' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => '驗證失敗',
|
||||
'errors' => $validator->errors()
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 創建用戶
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
'phone' => $request->phone,
|
||||
'role' => 'admin', // 強制為管理員角色
|
||||
]);
|
||||
|
||||
// 創建管理員資料
|
||||
AdminProfile::create([
|
||||
'user_id' => $user->id,
|
||||
'position' => $request->position,
|
||||
'department' => $request->department,
|
||||
]);
|
||||
|
||||
// 創建 API token
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'message' => '管理員註冊成功',
|
||||
'data' => [
|
||||
'user' => $user,
|
||||
'token' => $token,
|
||||
'token_type' => 'Bearer',
|
||||
]
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理員登入
|
||||
*/
|
||||
|
||||
@@ -5,15 +5,19 @@ namespace App\Http\Controllers\API;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CourseImage;
|
||||
use App\Models\DivingOffer;
|
||||
use App\Traits\CompressesImages;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CourseImageController extends Controller
|
||||
{
|
||||
use CompressesImages;
|
||||
|
||||
private function validateImage(Request $request): void
|
||||
{
|
||||
$request->validate([
|
||||
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:2048',
|
||||
// 10MB:伺服器端會壓縮(compressToJpeg),放寬以容納手機原圖
|
||||
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:10240',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -34,7 +38,7 @@ class CourseImageController extends Controller
|
||||
Storage::disk('public')->delete($offer->cover_image);
|
||||
}
|
||||
|
||||
$path = $request->file('image')->store("offers/{$offerId}/cover", 'public');
|
||||
$path = $this->compressToJpeg($request->file('image'), "offers/{$offerId}/cover");
|
||||
$offer->update(['cover_image' => $path]);
|
||||
|
||||
return response()->json([
|
||||
@@ -67,7 +71,7 @@ class CourseImageController extends Controller
|
||||
return response()->json(['status' => false, 'message' => '相簿最多 3 張圖片'], 422);
|
||||
}
|
||||
|
||||
$path = $request->file('image')->store("offers/{$offerId}/gallery", 'public');
|
||||
$path = $this->compressToJpeg($request->file('image'), "offers/{$offerId}/gallery");
|
||||
$sortOrder = ($offer->courseImages()->max('sort_order') ?? 0) + 1;
|
||||
|
||||
$image = CourseImage::create([
|
||||
|
||||
@@ -15,7 +15,7 @@ class DivingOfferController extends Controller
|
||||
$cacheKey = 'diving_offers_' . md5(serialize($request->all()));
|
||||
|
||||
$result = Cache::tags(['diving_offers'])->remember($cacheKey, 180, function () use ($request, $perPage) {
|
||||
$query = DivingOffer::query();
|
||||
$query = DivingOffer::query()->visibleToPublic();
|
||||
|
||||
if ($q = $request->query('q')) {
|
||||
$query->where(function ($sub) use ($q) {
|
||||
@@ -55,7 +55,7 @@ class DivingOfferController extends Controller
|
||||
|
||||
public function show(int $id)
|
||||
{
|
||||
$offer = DivingOffer::with('courseImages')->find($id);
|
||||
$offer = DivingOffer::with('courseImages')->visibleToPublic()->find($id);
|
||||
|
||||
if (!$offer) {
|
||||
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
|
||||
|
||||
@@ -45,9 +45,15 @@ class MemberBookingController extends Controller
|
||||
'notes' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$schedule = CourseSchedule::with('divingOffer')->findOrFail($data['schedule_id']);
|
||||
$schedule = CourseSchedule::with('divingOffer.provider.providerProfile')->findOrFail($data['schedule_id']);
|
||||
|
||||
// Layer 1:快速失敗
|
||||
// 可見性繞過防護:課程教練未通過審核時不可建立新預約(既有預約不受影響,見 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) {
|
||||
return response()->json(['status' => false, 'message' => '此時段不開放預約'], 422);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Enums\VerificationStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ProviderCertification;
|
||||
use App\Traits\CompressesImages;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProviderVerificationController extends Controller
|
||||
{
|
||||
use CompressesImages;
|
||||
|
||||
private const MAX_CERTIFICATIONS = 3;
|
||||
|
||||
public function show(Request $request)
|
||||
{
|
||||
$profile = $request->user()->providerProfile;
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => [
|
||||
'verification_status' => $profile->verification_status->value,
|
||||
'rejection_reason' => $profile->rejection_reason,
|
||||
'certifications' => $request->user()->providerCertifications
|
||||
->map(fn($c) => ['id' => $c->id, 'url' => $c->url])->values(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function uploadCertification(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:10240',
|
||||
]);
|
||||
|
||||
if ($err = $this->ensureCertificationsEditable($request)) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if ($user->providerCertifications()->count() >= self::MAX_CERTIFICATIONS) {
|
||||
return response()->json(['status' => false, 'message' => '證照最多 3 張'], 422);
|
||||
}
|
||||
|
||||
$path = $this->compressToJpeg($request->file('image'), "providers/{$user->id}/certifications");
|
||||
|
||||
$certification = ProviderCertification::create([
|
||||
'user_id' => $user->id,
|
||||
'image_path' => $path,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'message' => '證照已上傳',
|
||||
'data' => ['id' => $certification->id, 'url' => $certification->url],
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function deleteCertification(Request $request, int $id)
|
||||
{
|
||||
$certification = ProviderCertification::findOrFail($id);
|
||||
|
||||
if ($certification->user_id !== $request->user()->id) {
|
||||
return response()->json(['status' => false, 'message' => '無權刪除此證照'], 403);
|
||||
}
|
||||
|
||||
if ($err = $this->ensureCertificationsEditable($request)) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
$certification->delete();
|
||||
|
||||
return response()->json(['status' => true, 'message' => '證照已刪除']);
|
||||
}
|
||||
|
||||
public function submit(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$profile = $user->providerProfile;
|
||||
|
||||
if (!$profile->verification_status->canTransitionTo(VerificationStatus::Pending)) {
|
||||
return response()->json(['status' => false, 'message' => '當前狀態無法送審'], 422);
|
||||
}
|
||||
|
||||
if ($user->providerCertifications()->count() < 1) {
|
||||
return response()->json(['status' => false, 'message' => '請先上傳至少 1 張證照'], 422);
|
||||
}
|
||||
|
||||
$profile->update([
|
||||
'verification_status' => VerificationStatus::Pending,
|
||||
'rejection_reason' => null,
|
||||
]);
|
||||
|
||||
return response()->json(['status' => true, 'message' => '已送出審核,請等待平台審核結果']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 證照僅於 unsubmitted / rejected 可增刪:pending / approved 是審核依據,不可變動
|
||||
*/
|
||||
private function ensureCertificationsEditable(Request $request)
|
||||
{
|
||||
$status = $request->user()->providerProfile->verification_status;
|
||||
|
||||
if (!in_array($status, [VerificationStatus::Unsubmitted, VerificationStatus::Rejected])) {
|
||||
return response()->json(['status' => false, 'message' => '審核期間或通過後不可變更證照'], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ class ReviewController extends Controller
|
||||
|
||||
public function publicList(Request $request, int $offerId)
|
||||
{
|
||||
$offer = DivingOffer::findOrFail($offerId);
|
||||
$offer = DivingOffer::visibleToPublic()->findOrFail($offerId);
|
||||
$user = $request->user();
|
||||
$perPage = min((int) $request->query('per_page', 20), 50);
|
||||
$sort = $request->query('sort', 'helpful');
|
||||
|
||||
@@ -95,7 +95,7 @@ class ScheduleController extends Controller
|
||||
|
||||
public function publicList(int $offerId)
|
||||
{
|
||||
$offer = DivingOffer::findOrFail($offerId);
|
||||
$offer = DivingOffer::visibleToPublic()->findOrFail($offerId);
|
||||
$schedules = CourseSchedule::where('diving_offer_id', $offer->id)
|
||||
->where('status', ScheduleStatus::Open->value)
|
||||
->whereDate('scheduled_date', '>=', now()->toDateString())
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -53,6 +54,18 @@ class DivingOffer extends Model
|
||||
return $this->belongsTo(User::class, 'provider_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 公開端點可見性:未通過審核(approved)教練的課程不對外曝光。
|
||||
* provider_id 為 null 的課程(平台自有資料)不受此限制。
|
||||
*/
|
||||
public function scopeVisibleToPublic(Builder $query): Builder
|
||||
{
|
||||
return $query->where(function (Builder $visible) {
|
||||
$visible->whereNull('provider_id')
|
||||
->orWhereHas('provider.providerProfile', fn (Builder $profile) => $profile->where('verification_status', \App\Enums\VerificationStatus::Approved->value));
|
||||
});
|
||||
}
|
||||
|
||||
public function schedules()
|
||||
{
|
||||
return $this->hasMany(CourseSchedule::class, 'diving_offer_id');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 與用戶的關聯
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
|
||||
trait CompressesImages
|
||||
{
|
||||
/**
|
||||
* 壓縮上傳圖片並存入 public disk,回傳相對路徑。
|
||||
* 參數與聊天圖片管線一致(scaleDown 2048 + JPEG quality 85),
|
||||
* 確保平台內影像處理行為單一來源。
|
||||
*/
|
||||
private function compressToJpeg(UploadedFile $file, string $directory): string
|
||||
{
|
||||
$manager = new ImageManager(new Driver());
|
||||
$image = $manager->read($file);
|
||||
|
||||
if ($image->width() > 2048 || $image->height() > 2048) {
|
||||
$image->scaleDown(width: 2048, height: 2048);
|
||||
}
|
||||
|
||||
$path = trim($directory, '/') . '/' . Str::uuid() . '.jpg';
|
||||
|
||||
Storage::disk('public')->put($path, $image->toJpeg(quality: 85));
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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
|
||||
@@ -5,7 +5,7 @@ return [
|
||||
'documentations' => [
|
||||
'default' => [
|
||||
'api' => [
|
||||
'title' => 'L5 Swagger UI',
|
||||
'title' => 'CFDive Platform API',
|
||||
],
|
||||
|
||||
'routes' => [
|
||||
|
||||
+39
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -65,7 +65,7 @@ class DemoSeeder extends Seeder
|
||||
'services' => '體驗潛水,OW認證課程,AOW認證課程,夜潛,水下攝影',
|
||||
'certifications' => 'PADI 五星級潛水中心,PADI Course Director',
|
||||
'facilities' => '空氣填充站,氧氣填充站,裝備租借,沖洗區,更衣室,停車場',
|
||||
'business_hours' => '每日 07:30–18:00', 'is_verified' => true,
|
||||
'business_hours' => '每日 07:30–18: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:00–17:30', 'is_verified' => true,
|
||||
'business_hours' => '每日 07:00–17: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:00–17:00', 'is_verified' => true,
|
||||
'business_hours' => '每日 08:00–17: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:00–18:00', 'is_verified' => false,
|
||||
'business_hours' => '週一公休,週二至週日 08:00–18: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;
|
||||
|
||||
@@ -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
|
||||
|
||||
+26
-21
@@ -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
|
||||
@@ -60,7 +60,8 @@ services:
|
||||
app:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
|
||||
# busybox wget 對 localhost 會先解析到 IPv6 ::1,但 nginx 只 listen IPv4,須用 127.0.0.1
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -108,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:
|
||||
@@ -145,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
|
||||
@@ -158,6 +159,8 @@ services:
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- cfdive-network
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
@@ -204,3 +207,5 @@ networks:
|
||||
volumes:
|
||||
mysql-data:
|
||||
driver: local
|
||||
redis-data:
|
||||
driver: local
|
||||
|
||||
@@ -22,9 +22,12 @@ php -r "
|
||||
file_put_contents('/var/www/.env', \$env);
|
||||
"
|
||||
|
||||
# Composer 依賴(vendor 不存在時才裝,通常已存在於 volume)
|
||||
if [ -f "composer.json" ] && { [ ! -d "vendor" ] || [ "composer.json" -nt "vendor/autoload.php" ]; }; then
|
||||
# Composer 依賴(vendor 不存在或 lock 內容變更時才裝)
|
||||
# 用內容比對而非 mtime:git checkout/pull 會更新 composer.json 的 mtime,
|
||||
# mtime 比對會讓每次分支操作後的開機都重跑 autoload 生成(bind mount 上耗時數分鐘、期間全站 502)
|
||||
if [ -f "composer.lock" ] && { [ ! -d "vendor" ] || ! cmp -s composer.lock vendor/.composer.lock.installed; }; then
|
||||
composer install --no-scripts --optimize-autoloader
|
||||
cp composer.lock vendor/.composer.lock.installed
|
||||
fi
|
||||
|
||||
# 背景執行:等 MySQL → migration → cache clear → storage link → swagger
|
||||
@@ -56,8 +59,5 @@ fi
|
||||
echo "✅ [背景] 初始化完成"
|
||||
) &
|
||||
|
||||
# 啟動 cron(Laravel Scheduler)
|
||||
service cron start || cron || true
|
||||
|
||||
echo "🚀 啟動 php-fpm..."
|
||||
exec "$@"
|
||||
|
||||
@@ -3,3 +3,14 @@ post_max_size=40M
|
||||
memory_limit=512M
|
||||
max_execution_time=600
|
||||
max_input_vars=10000
|
||||
|
||||
; ── OPcache 調校(2026-06-11 效能優化 O1.1)──
|
||||
; 預設 revalidate_freq=2 在 Windows bind mount 上每 2 秒就重新 stat 全部
|
||||
; 已快取腳本(~850 檔),實測使每次閒置後的請求從 0.23s 暴增至 2.5s。
|
||||
; 拉長至 30 秒:程式碼變更最多 30 秒後生效;需要立即生效時執行
|
||||
; docker compose exec app kill -USR2 1(php-fpm 平滑重載,約 1 秒)
|
||||
opcache.enable=1
|
||||
opcache.memory_consumption=192
|
||||
opcache.max_accelerated_files=20000
|
||||
opcache.validate_timestamps=1
|
||||
opcache.revalidate_freq=30
|
||||
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
# CFDivePlatform 啟動指令
|
||||
|
||||
## 部署模式
|
||||
|
||||
專案根目錄有兩個 compose 檔:
|
||||
|
||||
| 檔案 | 用途 |
|
||||
|------|------|
|
||||
| `docker-compose.yml` | 正式服務(app、nginx、frontend、db、redis、reverb、queue-worker、scheduler) |
|
||||
| `compose.override.yml` | 開發工具(phpmyadmin、mailpit),已進版控 |
|
||||
|
||||
**本機 / VPS(預設)**:Docker Compose 自動合併兩檔,開發工具一同啟動:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**雲端正式環境**:明確指定單一檔案,排除開發工具:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml up -d
|
||||
```
|
||||
|
||||
> VPS 首次部署或更新後執行 `git pull` 即可取得最新的 `compose.override.yml`,不需手動建立。
|
||||
|
||||
---
|
||||
|
||||
## 專案位置
|
||||
|
||||
```powershell
|
||||
C:\laragon\www\CFDivePlatform
|
||||
```
|
||||
|
||||
## 1. 進入專案目錄
|
||||
|
||||
```powershell
|
||||
cd C:\laragon\www\CFDivePlatform
|
||||
```
|
||||
|
||||
## 2. 準備 `.env`
|
||||
|
||||
如果尚未建立 `.env`:
|
||||
|
||||
```powershell
|
||||
copy .env.example .env
|
||||
```
|
||||
|
||||
確認 `.env` 至少設定以下項目:
|
||||
|
||||
```env
|
||||
APP_KEY=
|
||||
DB_DATABASE=CFDivePlatform
|
||||
DB_USERNAME=cfdiveuser
|
||||
DB_PASSWORD=your_password
|
||||
MYSQL_ROOT_PASSWORD=your_root_password
|
||||
|
||||
REVERB_APP_ID=your_reverb_app_id
|
||||
REVERB_APP_KEY=your_reverb_app_key
|
||||
REVERB_APP_SECRET=your_reverb_app_secret
|
||||
VITE_REVERB_APP_KEY=your_reverb_app_key
|
||||
```
|
||||
|
||||
如果 `APP_KEY` 是空的,可在容器啟動後執行:
|
||||
|
||||
```powershell
|
||||
docker compose exec app php artisan key:generate
|
||||
```
|
||||
|
||||
## 3. 啟動 Docker 服務
|
||||
|
||||
第一次啟動,或 Dockerfile / compose 設定有變更時:
|
||||
|
||||
```powershell
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
平常啟動:
|
||||
|
||||
```powershell
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 4. 查看服務狀態
|
||||
|
||||
```powershell
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
查看所有服務 log:
|
||||
|
||||
```powershell
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
只查看 Laravel App log:
|
||||
|
||||
```powershell
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
## 5. 初始化與維護指令
|
||||
|
||||
容器啟動時會自動執行部分初始化流程:
|
||||
|
||||
- 安裝 Composer 依賴
|
||||
- 等待 MySQL 啟動
|
||||
- 執行 migration
|
||||
- 清除 Laravel cache
|
||||
- 建立 storage link
|
||||
- 產生 Swagger 文件
|
||||
|
||||
如需手動執行,可使用:
|
||||
|
||||
```powershell
|
||||
docker compose exec app composer install
|
||||
docker compose exec app php artisan migrate
|
||||
docker compose exec app php artisan storage:link
|
||||
docker compose exec app php artisan l5-swagger:generate
|
||||
```
|
||||
|
||||
清除 Laravel 快取:
|
||||
|
||||
```powershell
|
||||
docker compose exec app php artisan config:clear
|
||||
docker compose exec app php artisan cache:clear
|
||||
docker compose exec app php artisan route:clear
|
||||
docker compose exec app php artisan view:clear
|
||||
```
|
||||
|
||||
## 6. 建立管理員帳號
|
||||
|
||||
```powershell
|
||||
docker compose exec app php artisan app:create-admin "Admin" "admin@example.com" --password="your_password"
|
||||
```
|
||||
|
||||
密碼至少需要 8 碼。
|
||||
|
||||
## 7. 前端 Vite 開發模式
|
||||
|
||||
如果要使用本機 Vite 開發模式:
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
正式建置:
|
||||
|
||||
```powershell
|
||||
npm run build
|
||||
```
|
||||
|
||||
Docker Compose 內也有 `frontend` 服務,預設網址為:
|
||||
|
||||
```text
|
||||
http://localhost:5173
|
||||
```
|
||||
|
||||
## 8. 重啟 frontend
|
||||
|
||||
如果 frontend 是透過 Docker Compose 啟動:
|
||||
|
||||
```powershell
|
||||
docker compose restart frontend
|
||||
```
|
||||
|
||||
如果有修改 frontend 的 Dockerfile、環境變數或 build 內容,建議重建:
|
||||
|
||||
```powershell
|
||||
docker compose up -d --build frontend
|
||||
```
|
||||
|
||||
如果是使用本機 Vite 開發模式,先在原本終端機按 `Ctrl + C` 停止,再重新執行:
|
||||
|
||||
```powershell
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 9. 服務網址
|
||||
|
||||
| 服務 | URL |
|
||||
| --- | --- |
|
||||
| API / Laravel + Nginx | <http://localhost:8080> |
|
||||
| Frontend Docker | <http://localhost:5173> |
|
||||
| phpMyAdmin | <http://localhost:8081> |
|
||||
| Mailpit | <http://localhost:8025> |
|
||||
| Reverb WebSocket | <ws://localhost:8085> |
|
||||
| Health Check | <http://localhost:8080/health> |
|
||||
|
||||
## 10. Log 查看
|
||||
|
||||
Laravel 日誌走 stderr,透過 Docker 查看(不再有 `storage/logs/` 檔案):
|
||||
|
||||
```bash
|
||||
# 即時串流
|
||||
docker compose logs -f app
|
||||
|
||||
# 查看最後 50 行
|
||||
docker compose logs app --tail=50
|
||||
```
|
||||
|
||||
## 11. VPS 維護窗口操作(P1 部署)
|
||||
|
||||
PR merge 後 CI/CD 自動跑 composer/migrate/cache,完成後 SSH 進 VPS 執行:
|
||||
|
||||
```bash
|
||||
cd /root/myproject/CFDivePlatform
|
||||
|
||||
# 1. 更新 .env(一次性)
|
||||
sed -i 's/SESSION_DRIVER=database/SESSION_DRIVER=redis/' .env
|
||||
sed -i 's/LOG_CHANNEL=stack/LOG_CHANNEL=stderr/' .env
|
||||
sed -i '/^LOG_STACK=/d' .env
|
||||
sed -i '/^LOG_DAILY_DAYS=/d' .env
|
||||
|
||||
# 2. Rebuild + 重啟(entrypoint 自動 config:clear)
|
||||
docker compose up -d --build
|
||||
|
||||
# 3. 驗證
|
||||
docker compose ps # scheduler 應顯示 Up
|
||||
docker compose logs app --tail=20 # 應有 stderr 日誌
|
||||
docker compose exec redis redis-cli keys "laravel_session*" # 登入後應有 session key
|
||||
docker compose exec app sh -c "which cron 2>/dev/null || echo 'cron binary not found'" # 應輸出 not found
|
||||
```
|
||||
|
||||
**Rollback(若需還原):**
|
||||
|
||||
Session/Log 還原(不需 rebuild):
|
||||
```bash
|
||||
sed -i 's/SESSION_DRIVER=redis/SESSION_DRIVER=database/' .env
|
||||
sed -i 's/LOG_CHANNEL=stderr/LOG_CHANNEL=stack/' .env
|
||||
echo "LOG_STACK=daily" >> .env
|
||||
echo "LOG_DAILY_DAYS=14" >> .env
|
||||
docker compose exec app php artisan config:clear
|
||||
docker compose restart app
|
||||
```
|
||||
|
||||
Scheduler 還原(需 rebuild):
|
||||
```bash
|
||||
git revert <commit-hash>
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## 12. 停止服務
|
||||
|
||||
停止容器,但保留資料庫 volume:
|
||||
|
||||
```powershell
|
||||
docker compose down
|
||||
```
|
||||
|
||||
停止容器並移除 volume,會清除 MySQL 資料:
|
||||
|
||||
```powershell
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
## 最短啟動流程
|
||||
|
||||
```powershell
|
||||
cd C:\laragon\www\CFDivePlatform
|
||||
copy .env.example .env
|
||||
docker compose up -d --build
|
||||
docker compose exec app php artisan key:generate
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
啟動完成後開啟:
|
||||
|
||||
```text
|
||||
http://localhost:8080
|
||||
```
|
||||
@@ -0,0 +1,100 @@
|
||||
# CFDivePlatform 未來發展可行性評估
|
||||
|
||||
> 評估日期:2026-06-11
|
||||
> 前提:核心閉環(瀏覽 → 預約 → 聊天 → 完課 → 評價)已完成並歸檔;本文評估下一階段候選項目的可行性、依賴與建議順序。
|
||||
> 搭配閱讀:《2026-06-11-spec-implementation-audit.md》(修補計畫應先於所有新功能執行,尤其 P0-1)。
|
||||
|
||||
---
|
||||
|
||||
## 一、候選項目總覽
|
||||
|
||||
| # | 項目 | 可行性 | 預估工時 | 商業價值 | 建議優先序 |
|
||||
|---|------|--------|---------|---------|-----------|
|
||||
| 1 | 教練審核流程(完整版) | 高 | 3~5 天 | 高(信任基礎) | ★ 第 1 |
|
||||
| 2 | 金流整合(綠界/藍新) | 中高 | 8~12 天 | 極高(變現核心) | ★ 第 2 |
|
||||
| 3 | 即時聊天室 VPS 部署驗收 | 高 | 0.5~1 天 | 中(已開發未交付) | 隨時可做 |
|
||||
| 4 | CI/CD(Gitea Actions)修復 | 中 | 1~2 天 | 中(工程效率) | 金流前必做 |
|
||||
| 5 | 課程搜尋強化(地區/價格/日期篩選) | 高 | 2~3 天 | 中高 | 第 3 |
|
||||
| 6 | 行動端體驗(PWA) | 中 | 5~8 天 | 中 | 觀望 |
|
||||
|
||||
---
|
||||
|
||||
## 二、逐項評估
|
||||
|
||||
### 2.1 教練審核流程(完整版)— 可行性:高
|
||||
|
||||
**現況基礎**:`provider_profiles.is_verified` 欄位、Admin `toggle-verified` 端點、Admin Panel UI 均已存在;缺的是審核「流程」與約束力(見稽核報告 P1-1)。
|
||||
|
||||
**範圍建議**:
|
||||
1. 教練註冊後狀態為 `pending_review`(建議將 boolean `is_verified` 升級為 enum `verification_status`:pending / approved / rejected)。
|
||||
2. 證照/資料上傳:複用既有 `CourseImageController` 的檔案處理模式(local storage,已有上傳驗證先例)。
|
||||
3. Admin 審核佇列頁 + 通過/駁回(含駁回原因),複用既有 Admin Panel 的列表/操作 UI 模式。
|
||||
4. 審核結果通知:複用 `notification-core` + `notification-email` 既有管線,只需新增 Notification class 與 trigger。
|
||||
|
||||
**技術風險**:低。所有積木(檔案上傳、通知、Admin UI、權限 middleware)都已存在,純組裝工作。
|
||||
**主要決策點**:未通過審核的教練能做到哪一步(可登入編輯資料但不可上架?完全不可登入?)——需產品決策,建議前者。
|
||||
**依賴**:先完成稽核報告 T2.1(最小語意),再擴充為完整流程,避免重工。
|
||||
|
||||
### 2.2 金流整合 — 可行性:中高(工程可行,依賴外部申請流程)
|
||||
|
||||
**金流商比較(台灣市場)**:
|
||||
|
||||
| 面向 | 綠界 ECPay | 藍新 NewebPay |
|
||||
|------|-----------|---------------|
|
||||
| 申請門檻 | 個人/公司皆可,測試環境免申請即用 | 偏好公司戶,測試環境需註冊 |
|
||||
| 文件/社群資源 | Laravel 套件與範例多(生態最成熟) | 文件完整但第三方資源較少 |
|
||||
| 手續費 | 信用卡約 2.75%~ | 相近 |
|
||||
| 結論 | **建議首選**,沙箱即開即用、整合範例多 | 備選 |
|
||||
|
||||
**對現有架構的影響(這是工時主要來源)**:
|
||||
1. **預約狀態機需擴充**:現有七狀態未含付款概念,需插入 `awaiting_payment`、`paid` 等狀態或建立獨立 `payments` 資料表(建議後者:獨立 Payment model + 與 Booking 一對多,狀態機改動最小,符合開放封閉原則)。
|
||||
2. **Webhook 端點**:綠界以 server-side POST 回傳付款結果,需公開端點 + CheckMacValue 驗證 + 冪等處理(重複通知不可重複入帳)——此為資安重點,須走 CLAUDE.md 安全確認流程。
|
||||
3. **退款政策**:取消預約的退款規則(全額/比例/不退)是產品決策,直接影響狀態機與對帳邏輯,**須在開工前定案**。
|
||||
4. **金額快照**:現有 booking 已記錄價格快照(`booking-lifecycle` 規格),付款金額有可靠依據,這是現成優勢。
|
||||
|
||||
**前置條件**:
|
||||
- [ ] 確定金流商並取得測試商店(綠界沙箱可立即取得)。
|
||||
- [ ] 退款政策定案。
|
||||
- [ ] **稽核報告 P0-1 必須先修**(金流上線後,管理端漏洞的損害會從資料外洩升級為金錢損失)。
|
||||
- [ ] 建議 CI 先行(2.4),金流程式碼回歸成本高,不應依賴手動測試。
|
||||
|
||||
**工時拆解(粗估)**:Payment model + 狀態整合 3 天、ECPay 串接與 Webhook 3 天、前端付款流程 2 天、測試(含冪等與失敗路徑)2~4 天。
|
||||
|
||||
### 2.3 即時聊天室 VPS 部署驗收 — 可行性:高
|
||||
|
||||
程式碼已 merge(commit 5ba598c 後系列),記憶中部署狀態未確認。剩餘工作為運維執行:rebuild frontend、restart reverb、`.env` 五個值(細節在既有部署清單中)。建議排入下次部署窗口一次完成,避免長期掛著「已完成未交付」狀態。
|
||||
|
||||
### 2.4 CI/CD(Gitea Actions)修復 — 可行性:中
|
||||
|
||||
已知 runner 的 self-hosted 標籤問題尚未解決。價值在於:測試套件已有 97 案例且將隨修補計畫 Phase 3 成長,沒有 CI 的測試套件會逐漸失去約束力。建議在金流開工前修復,作為金流開發的安全網。風險:Gitea runner 在 Windows/Docker 環境的相容性問題可能再耗時,若兩天內無法解決,備案是改用簡單的 pre-push hook 或部署前手動 gate。
|
||||
|
||||
### 2.5 課程搜尋強化 — 可行性:高
|
||||
|
||||
現有 `GET /api/diving-offers` 已支援關鍵字與分頁;擴充地區/價格區間/可預約日期篩選屬增量開發。注意事項:日期篩選需 join schedules 並留意 N+1 與索引(`db-index-optimization` 規格已有索引設計先例可循);快取層(`api-cache-layer`)的 cache key 需納入新篩選參數。
|
||||
|
||||
### 2.6 行動端 PWA — 可行性:中,建議觀望
|
||||
|
||||
Vue 3 + Vite 加 PWA plugin 技術上直接,但通知推播(Web Push)與 iOS 限制會吃掉多數工時,且現階段使用者基數未驗證。建議待金流上線、有真實流量後再評估。
|
||||
|
||||
---
|
||||
|
||||
## 三、建議路線圖
|
||||
|
||||
```
|
||||
Week 1 修補計畫 Phase 1~4(P0 封鎖 + 規格同步 + 預約測試)
|
||||
└─ 並行:聊天室 VPS 部署驗收(2.3)
|
||||
Week 2 教練審核流程完整版(2.1)+ CI 修復(2.4)
|
||||
Week 3~4 金流整合(2.2):Payment model → ECPay 沙箱 → Webhook → 前端
|
||||
Week 5 金流測試強化 + 課程搜尋強化(2.5)
|
||||
```
|
||||
|
||||
**關鍵判斷**:
|
||||
1. 教練審核排在金流之前——付費平台上架未經審核的教練,發生糾紛時平台責任更重;審核機制是金流的信任前提。
|
||||
2. 金流是唯一含外部依賴(商店申請、Webhook 公網可達性)的項目,沙箱申請應在 Week 1 就先送出,與其他工作並行等待。
|
||||
3. 所有新功能開工前,稽核報告的 P0-1(admin register 漏洞)必須先關閉。
|
||||
|
||||
## 四、不建議現在做的事
|
||||
|
||||
- **微服務化 / 抽換架構**:單體 Laravel 在現階段流量下完全足夠,拆分只會增加部署與除錯成本。
|
||||
- **多金流商抽象層**:先用介面包一層 `PaymentGatewayContract` 即可(依賴反轉),但不要實作第二家金流——沒有需求前是投機性程式碼(違反 Simplicity First)。
|
||||
- **評價 AI 審核 / 推薦系統**:資料量不足,無訓練與評估基礎。
|
||||
@@ -0,0 +1,69 @@
|
||||
# CFDivePlatform 操作體驗優化計畫
|
||||
|
||||
> 撰寫日期:2026-06-11
|
||||
> 背景:使用者反映本機操作體驗差、回應慢。本文以實測數據定位根因,並依投資報酬率排序優化項目。
|
||||
> **狀態更新(2026-06-11)**:Phase 1 已執行完成,穩態延遲 2.5s → **0.2s**(約 10 倍改善)。執行結果見第三節,根因分析已依實測修正。
|
||||
|
||||
---
|
||||
|
||||
## 一、實測基準(2026-06-11 上午,修復前)
|
||||
|
||||
| 量測項目 | 數值 | 備註 |
|
||||
|---------|------|------|
|
||||
| `GET /api/diving-offers`(冷) | **7.98 秒** | 經 nginx:8080 → php-fpm |
|
||||
| 同端點(Redis 快取命中、間隔數秒) | **2.3~2.5 秒** | 快取命中仍要 2.5 秒 → 瓶頸不在查詢 |
|
||||
| 同端點(2 秒內連發) | 0.23 秒 | 關鍵線索:短窗口內其實很快 |
|
||||
| 容器內全測試套件 | 64.4 秒 | 同套件本機 PHP 僅 4.5 秒 |
|
||||
|
||||
## 二、根因分析(依實測修正)
|
||||
|
||||
> ⚠️ 初版文檔判定「OPcache 被停用」是**錯誤診斷**——當時用 `php -r`(CLI)量測,而 `opcache.enable_cli` 預設關閉造成假象。php-fpm 的 OPcache 一直是啟用的(851 個腳本在快取中)。正確根因如下:
|
||||
|
||||
1. **`opcache.revalidate_freq=2`(預設值)× Windows bind mount**:每隔 2 秒,下一個請求就要跨 Windows↔Linux 檔案系統邊界重新 stat 全部 ~850 個已快取腳本。實測鐵證:2 秒窗口內 0.23s,窗口過期後 2.3~2.5s。一般操作節奏(點一下、看一下、再點)幾乎每次都踩在窗口外,所以「每個操作都慢」。
|
||||
2. **Entrypoint 的 composer 重裝條件用 mtime 比對**:`composer.json -nt vendor/autoload.php`——git checkout/pull 會更新 composer.json 的 mtime,導致每次分支操作後的容器啟動都重跑 `composer install`(bind mount 上 autoload 生成耗時數分鐘,**期間 php-fpm 未啟動、全站 502**)。
|
||||
3. **nginx healthcheck 誤報**:busybox wget 對 `localhost` 先解析 IPv6 `::1`,nginx 只 listen IPv4 → 永遠 connection refused → 永遠 unhealthy。
|
||||
4. 觀察項(未處理):cron 每分鐘 `schedule:run` 以 CLI 啟動 PHP(CLI 無 OPcache + bind mount),造成偶發 ~2 秒突波;`SESSION/QUEUE=database`;`APP_DEBUG=true`。
|
||||
|
||||
## 三、Phase 1 執行紀錄(2026-06-11 完成,branch perf/phase1-opcache)
|
||||
|
||||
| 項目 | 修改 | 結果 |
|
||||
|------|------|------|
|
||||
| O1.1 OPcache 調校 | `docker/php/local.ini`:`revalidate_freq` 2→**30**、memory 128→192、max_files 10000→20000 | 穩態 **0.20~0.27s**(前:2.3~2.5s);每 30 秒閒置後僅一次 ~1.2s revalidate |
|
||||
| O1.2 healthcheck 修復 | docker-compose.yml:`http://localhost/` → `http://127.0.0.1/` | nginx 顯示 **healthy** |
|
||||
| O1.3 composer 重裝條件 | entrypoint 改為 composer.lock **內容比對**(`cmp` vs `vendor/.composer.lock.installed` 標記檔) | git 分支操作不再觸發開機重裝與 502 窗口 |
|
||||
|
||||
### 修復後量測(同一端點、每 5 秒一次)
|
||||
|
||||
```
|
||||
#1: 1.96s(cron 突波) #2: 0.22s #3: 0.21s #4: 0.20s #5: 0.27s #6: 0.26s
|
||||
閒置 35 秒後:1.19s(revalidate,每 30 秒至多一次)→ 下一次 0.30s
|
||||
```
|
||||
|
||||
### 開發注意事項
|
||||
|
||||
- 後端程式碼變更最多 **30 秒**後生效;要立即生效:`docker compose exec app kill -USR2 1`(php-fpm 平滑重載,約 1 秒)
|
||||
- `local.ini` 與 entrypoint 是 build 進 image 的,改動需 `docker compose build app && docker compose up -d app`
|
||||
- 此設定對 VPS 同樣適用且有益(Linux 原生 stat 便宜,revalidate=30 純賺)
|
||||
|
||||
## 四、後續階段(依新數據重新評估)
|
||||
|
||||
### Phase 2 — 結構性(暫緩)
|
||||
|
||||
原計畫的 vendor named volume / WSL2 遷移以「Phase 1 後仍 >1s」為觸發條件——實測穩態 0.2s,**未達觸發條件,暫緩**。若未來 cron 突波造成困擾,優先處理觀察項 4(如 schedule:run 改由長駐 worker、或縮減無任務時的 bootstrap)。
|
||||
|
||||
### Phase 3 — 應用層體感(與環境無關,部署後使用者也受益,1~2 天)
|
||||
|
||||
**O3.1 課程封面圖未壓縮**:聊天圖片已有 `scaleDown(2048) + toJpeg(85)`(`BookingMessageController.php:115`),但 `CourseImageController` 原檔直存。建議套用相同縮圖管線 + 前端 `loading="lazy"`。應開 openspec change(影響 `course-image-upload` 規格)。
|
||||
|
||||
**O3.2 無分頁的列表端點**:`/api/member/bookings`、`/api/provider/bookings`、`/api/admin/bookings` 皆 `->get()` 全量。資料量成長後線性變慢,建議金流上線前補分頁。
|
||||
|
||||
**O3.3 前端載入體感**:列表頁 skeleton 佔位、搜尋 debounce(300ms)。路由已全面 lazy-load(已驗證,不需處理)。
|
||||
|
||||
### 生產環境註記(部署時順手做)
|
||||
|
||||
- `php artisan optimize`(config / route / view cache)
|
||||
- 生產 OPcache 可進一步 `validate_timestamps=0`(部署時 `kill -USR2 1` 重載刷新)
|
||||
|
||||
## 五、驗收方式
|
||||
|
||||
每步以同一 curl 量測對照(`curl -s -o /dev/null -w "%{time_total}" http://127.0.0.1:8080/api/diving-offers`),數據寫回本文件。
|
||||
@@ -0,0 +1,84 @@
|
||||
# CFDivePlatform 規格與實作分析報告暨修補紀錄
|
||||
|
||||
> 分析日期:2026-06-11
|
||||
> 分析範圍:`openspec/specs/`(32 份規格)vs 後端實作(Laravel 11)、前端(`frontend/` Vue 3)、測試(`tests/`)
|
||||
> 分析基準:branch `test/auth-tests-coverage`(commit ca5b843)
|
||||
> **狀態更新(2026-06-11)**:修補計畫四階段已全部完成於 branch `fix/audit-remediation`(PR #28),本文件保留發現紀錄與執行結果,未完成事項見第四節。
|
||||
|
||||
---
|
||||
|
||||
## 一、現況總覽
|
||||
|
||||
### 1.1 規格狀態
|
||||
|
||||
- OpenSpec 規格共 **32 份**(修補後 33 份,新增 `provider-verification`),全部 change 已歸檔。
|
||||
- 涵蓋範圍:認證安全(lockout / rate-limiting / OAuth state / token refresh)、三角色入口(Member / Coach / Admin)、預約生命週期、評價系統、通知系統、即時聊天、Swagger 文件、效能優化。
|
||||
|
||||
### 1.2 實作狀態(修補後)
|
||||
|
||||
| 模組 | 規格 | 實作 | 測試 |
|
||||
|------|------|------|------|
|
||||
| 認證(三角色登入/註冊/登出) | ✅ | ✅ | ✅ |
|
||||
| 帳號鎖定 + OAuth state | ✅ | ✅ | ✅ |
|
||||
| Token Refresh | ✅ | ✅ | ✅ |
|
||||
| 課程 CRUD + 圖片上傳 | ✅ | ✅ | ✅ |
|
||||
| 預約生命週期(七狀態機、防超賣、Scheduler) | ✅ | ✅ | ✅(修補新增 26 案例) |
|
||||
| 評價系統 | ✅ | ✅ | ✅ |
|
||||
| 通知系統 | ✅ | ✅ | ⚠️ 僅 Scheduler 路徑覆蓋 |
|
||||
| 即時聊天 + Presence | ✅ | ✅ | ✅(授權路徑,修補新增 8 案例) |
|
||||
| Admin 管理端點 | ✅ | ✅ | ✅(權限邊界,修補新增 5 案例) |
|
||||
| 教練驗證(is_verified 最小語意) | ✅(修補新增) | ✅ | ✅(7 案例) |
|
||||
| 教練審核流程(完整版) | ❌ 無規格 | ❌ | — |
|
||||
| 金流整合 | ❌ 無規格 | ❌ | — |
|
||||
|
||||
---
|
||||
|
||||
## 二、稽核發現(保留原始紀錄,狀態見第三節)
|
||||
|
||||
### 🔴 P0-1:`POST /api/admin/register` 完全公開,任何人可註冊管理員帳號
|
||||
|
||||
- 該路由無任何 middleware、無邀請碼;request 通過基本驗證後直接 `role => 'admin'` 建立帳號。攻擊者一個 HTTP 請求即可取得全平台管理權限。`admin-auth` 規格從未定義此端點(規格外實作)。
|
||||
|
||||
### 🟠 P1-1:`is_verified`(教練驗證)從未被任何業務邏輯強制執行
|
||||
|
||||
- `is_verified` 只有 Admin toggle 開關與 Model 屬性,教練註冊後不需任何審核即可上架、曝光、接單。無任何規格定義其業務語意。
|
||||
|
||||
### 🟠 P1-2:登入頻率限制規格與實作不一致(規格漂移)
|
||||
|
||||
- 規格寫 Member/Provider 5 次/分鐘,實作 `throttle:10,1`,測試(commit 0dabc4e)已配合實作改為 10 次,規格未同步。
|
||||
|
||||
### 🟡 P2-1:核心業務流程(預約)零測試覆蓋
|
||||
|
||||
- 預約狀態機、防超賣鎖定、Scheduler 自動過期/完成沒有任何測試;時段管理、通知觸發、聊天授權、Admin 端點亦無。
|
||||
|
||||
### 🟡 P2-2:規格維護債
|
||||
|
||||
- `auth-test-coverage` Purpose 為 TBD;`member-portal-ui` repo 描述過時;`admin-auth` 未涵蓋 check-member / check-provider 端點。
|
||||
|
||||
---
|
||||
|
||||
## 三、修補執行紀錄(2026-06-11 全部完成)
|
||||
|
||||
分支 `fix/audit-remediation`(基底 `test/auth-tests-coverage`,PR #28),容器內全套件 **146 passed / 378 assertions**。
|
||||
|
||||
| 對應發現 | 修補內容 | Commit |
|
||||
|---------|---------|--------|
|
||||
| P0-1 | 移除公開 admin/register(路由/controller/Swagger),新增 `php artisan app:create-admin`(min:8),`AdminAccountCreationTest` 4 案例,`admin-auth` 規格補「帳號建立途徑」 | aeb8c97 |
|
||||
| P1-2 | `login-rate-limiting` 規格同步至 10/min(以實作為準,註記放寬理由) | 88a81aa |
|
||||
| P1-1 | `DivingOffer::visibleToPublic` scope(公開 index/show 過濾未驗證教練課程)、toggle-verified 後 flush 快取、新增 `provider-verification` 規格、`DivingOfferVisibilityTest` 7 案例 | 3c38d08 |
|
||||
| P2-1 | 預約核心 39 案例:狀態機 17、防超賣 3、Scheduler 6、聊天授權 8、Admin 權限邊界 5 | 63b25f9 |
|
||||
| P2-2 | 規格清理(Purpose / repo 描述 / 查詢端點)+ 同步移除測已刪端點的 2 個舊測試 | d3a0d30, cec2078 |
|
||||
| — | OpenSpec 補歸檔 `2026-06-11-audit-remediation`(proposal / design / tasks / 規格增量) | a97a7d0 |
|
||||
|
||||
**與原計畫的偏差**:原計畫建議「Demo seeder 將教練預設 `is_verified=true`」,實際保留 seeder 中 1 位未驗證教練——其課程從公開列表消失正好展示新規則,屬刻意決策(記錄於 PR #28 說明)。
|
||||
|
||||
## 四、尚未處理事項
|
||||
|
||||
1. **provider-verification 已知限制**(規格 Notes 已載明):`GET /api/diving-offers/{id}/schedules`、`GET /api/diving-offers/{id}/reviews` 與預約建立流程尚未套用可見性過濾,知道課程 id 仍可間接互動。
|
||||
→ **已開 OpenSpec change `provider-verification-gaps` 規劃**(`openspec/changes/provider-verification-gaps/`),待實作。
|
||||
2. **操作體驗優化**:見 `docs/analysis/2026-06-11-performance-optimization-plan.md`(O1.1 啟用 OPcache 起步),尚未實施。
|
||||
3. **新功能路線圖**:教練審核完整版、金流整合等,見 `docs/analysis/2026-06-11-future-roadmap-feasibility.md`。
|
||||
|
||||
## 五、規格同步狀態聲明
|
||||
|
||||
修補已同步的規格:`admin-auth`、`login-rate-limiting`、`auth-test-coverage`、`member-portal-ui`、新增 `provider-verification`;變更軌跡歸檔於 `openspec/changes/archive/2026-06-11-audit-remediation/`。
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ defineProps({
|
||||
v-if="offer.cover_image_url"
|
||||
:src="offer.cover_image_url"
|
||||
:alt="offer.title"
|
||||
loading="lazy"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="bg-gradient-to-br from-ocean-700 to-ocean-500 h-full flex items-center justify-center text-white text-5xl">
|
||||
|
||||
@@ -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') },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ async function submitBooking() {
|
||||
target="_blank"
|
||||
class="w-24 h-20 rounded-xl overflow-hidden shrink-0 border-2 border-transparent hover:border-ocean-400 transition"
|
||||
>
|
||||
<img :src="img.url" class="w-full h-full object-cover" />
|
||||
<img :src="img.url" loading="lazy" class="w-full h-full object-cover" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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-11
|
||||
@@ -0,0 +1,35 @@
|
||||
# Design — 2026-06-11 稽核修補
|
||||
|
||||
## D1:admin register 封鎖方式——artisan command 而非 middleware 防護
|
||||
|
||||
候選方案:
|
||||
- (a) 移除公開路由,改 `php artisan app:create-admin`(採用)
|
||||
- (b) 端點移入 `auth:sanctum + admin` middleware group(僅既有 admin 可開新 admin)
|
||||
|
||||
選 (a) 的理由:(b) 有「第一位管理員」雞生蛋問題,且保留 HTTP 入口就保留攻擊面;管理員建立頻率極低,不值得為其維護一個網路端點。command 密碼門檻設 min:8(高於一般使用者 min:6),因管理權限影響全平台。Seeder 直接以 Model 建立 admin,不受影響。
|
||||
|
||||
## D2:rate-limit 漂移以實作(10/min)為基準
|
||||
|
||||
測試已在 commit 0dabc4e 有意配合實作改為 10 次,且 P2 帳號鎖定上線後已涵蓋暴力破解防護(per-email 計數),IP-based throttle 的角色退為防濫用,放寬至 10/min 可容納共享 IP(公司/學校 NAT)場景。故更新規格而非改回實作。Admin 維持 3/min 不變。
|
||||
|
||||
## D3:is_verified 最小語意——列表過濾而非禁止上架
|
||||
|
||||
候選方案:
|
||||
- (a) 未驗證教練課程從公開 index/show 排除(採用)
|
||||
- (b) 未驗證教練 `store()` 回 403(禁止建立課程)
|
||||
|
||||
選 (a) 的理由:對教練的破壞較小(仍可登入、預先準備課程內容,通過驗證即曝光),可逆性高;(b) 會讓新教練在審核期間完全無事可做。實作為 `DivingOffer::scopeVisibleToPublic`(單一來源,index/show 共用):`provider_id` 為 null(平台自有資料)不受限,否則要求 `provider.providerProfile.is_verified = true`(無 profile 視同未驗證)。
|
||||
|
||||
快取一致性:公開列表有 180 秒 Redis tag 快取,`toggleProviderVerified` 後必須 `Cache::tags(['diving_offers'])->flush()`,否則切換最長延遲 3 分鐘生效。測試以「先打列表進快取 → toggle → 再打列表」驗證立即生效。
|
||||
|
||||
## D4:預約測試的保護目標(Rule 9——測試編碼 WHY)
|
||||
|
||||
- **狀態機**:終態(completed/rejected/expired/cancelled)不可再轉移——保護名額帳務與評價資格(只有 completed 能評價)
|
||||
- **防超賣**:pending 不佔名額是刻意設計(教練未承諾前不鎖位),防線在 confirm 時 `lockForUpdate` 二次驗證;不變式為「confirmed 總人數 ≤ 容量」
|
||||
- **Scheduler**:48h 過期與日期完成的邊界——過早 expire 砍掉教練來得及確認的單、過早 complete 讓未上課預約取得評價資格
|
||||
- **聊天授權**:HTTP 端點與 presence channel 雙防線都必須擋非參與方與非 confirmed 狀態
|
||||
- **Admin 邊界**:非 admin 一律 403;Admin 也不可繞過狀態機(rejected 不可 complete)
|
||||
|
||||
## D5:分支基底
|
||||
|
||||
`fix/audit-remediation` 基底為 `test/auth-tests-coverage`(而非 master),因 PR #27 的 auth 測試實際尚未併入 master,而本次修補需在其測試之上修改 `AuthLoginTest`。merge 本 change 的 PR #28 會一併帶入 PR #27 內容。
|
||||
@@ -0,0 +1,40 @@
|
||||
## Why
|
||||
|
||||
2026-06-11 規格與實作稽核(`docs/analysis/2026-06-11-spec-implementation-audit.md`)發現四項問題:
|
||||
|
||||
1. **P0**:`POST /api/admin/register` 完全公開且無任何防護,任何人一個 HTTP 請求即可註冊管理員帳號,使 P0~P2 認證安全強化形同虛設。該端點不在任何規格內(規格外實作)。
|
||||
2. **P1**:`provider_profiles.is_verified` 從未被業務邏輯強制執行——教練免審核即可上架課程、被公開曝光,Admin 的驗證開關是純展示功能,且無任何規格定義其語意。
|
||||
3. **P1**:`login-rate-limiting` 規格寫 5/min,實作與測試為 `throttle:10,1`(規格漂移,測試已在 commit 0dabc4e 改為配合實作但規格未同步)。
|
||||
4. **P2**:預約狀態機、防超賣、Scheduler 自動轉移——平台核心業務——零測試覆蓋。
|
||||
|
||||
> 註:本 change 為事後補歸檔。稽核報告實質扮演 proposal 角色,實作先於本文件完成(branch `fix/audit-remediation`,PR #28)。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **移除** `POST /api/admin/register` 路由、`AuthController::registerAdmin`、對應 Swagger 文件
|
||||
- **新增** `php artisan app:create-admin` command(密碼門檻 min:8),管理員帳號僅限主機端建立
|
||||
- **新增** `DivingOffer::visibleToPublic` scope:公開 index/show 排除未驗證教練的課程(`provider_id` null 不受限);`toggle-verified` 後 flush `diving_offers` 快取
|
||||
- **同步** `login-rate-limiting` 規格門檻至實作值 10/min
|
||||
- **新增測試**:`AdminAccountCreationTest`(4)、`DivingOfferVisibilityTest`(7)、`BookingLifecycleTest`(17)、`BookingOversellTest`(3)、`BookingSchedulerTest`(6)、`BookingChatAuthTest`(8)、`AdminEndpointAuthTest`(5)
|
||||
- **移除** `AuthLoginTest` 中測已刪端點的 2 個 admin register 測試
|
||||
- **規格清理**:補 `auth-test-coverage` Purpose、修正 `member-portal-ui` repo 描述
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `provider-verification`:定義 `is_verified` 的最小業務語意——未驗證教練的課程不對公開端點曝光、切換立即生效、教練自有管理端點不受限
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `admin-auth`:新增「管理員帳號建立途徑」requirement(公開註冊端點關閉、僅限 `app:create-admin`);補「管理員查詢指定用戶資料」端點規格
|
||||
- `login-rate-limiting`:Member/Provider 門檻 5/min → 10/min(與實作一致,帳號鎖定已涵蓋暴力破解防護)
|
||||
- `auth-test-coverage`:admin register 場景改寫為「公開註冊端點保持關閉」;補歸檔遺留的 TBD Purpose
|
||||
- `member-portal-ui`:前端位置由「獨立 repo」修正為本 repo `frontend/` 目錄
|
||||
|
||||
## Impact
|
||||
|
||||
- **行為變更**:未驗證教練(含 DemoSeeder 中 1 位 `is_verified=false` 的展示教練)課程從公開列表消失、詳情回 404;依賴 `POST /api/admin/register` 的外部工具需改用 `app:create-admin`
|
||||
- **影響檔案**:`routes/api.php`、`AuthController`、`AuthApiDoc`、`DivingOffer`、`DivingOfferController`、`AdminUserController`、新增 `app/Console/Commands/CreateAdminUser.php`、`tests/Feature/`(新增 7 檔)
|
||||
- **已知限制**(留待完整教練審核流程):`/diving-offers/{id}/schedules`、`/diving-offers/{id}/reviews` 與預約建立流程未套用相同過濾
|
||||
- 驗證:容器內 `php artisan test` 146 passed / 378 assertions
|
||||
@@ -0,0 +1,29 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 管理員帳號建立途徑
|
||||
管理員帳號 SHALL 僅能透過主機端 `php artisan app:create-admin` command 或資料庫 seeder 建立。系統 MUST NOT 提供任何公開的管理員註冊 HTTP 端點(原 `POST /api/admin/register` 已於 2026-06-11 因 P0 安全漏洞移除)。command 建立的密碼門檻為至少 8 碼,高於一般使用者。
|
||||
|
||||
#### Scenario: 公開註冊端點保持關閉
|
||||
- **WHEN** 任何人(含未認證請求)送出 `POST /api/admin/register`
|
||||
- **THEN** 回傳 HTTP 404,且不建立任何帳號
|
||||
|
||||
#### Scenario: 主機端建立管理員成功
|
||||
- **WHEN** 操作者於主機執行 `php artisan app:create-admin {name} {email} --password={password}` 且資料合法
|
||||
- **THEN** 建立 role=admin 的 User 與對應 AdminProfile
|
||||
|
||||
#### Scenario: 密碼過弱或 email 重複
|
||||
- **WHEN** command 收到少於 8 碼的密碼或已存在的 email
|
||||
- **THEN** command 以失敗結束,不建立任何帳號
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 管理員查詢指定用戶資料
|
||||
後端 SHALL 提供 `GET /api/admin/check-member/{id}` 與 `GET /api/admin/check-provider/{id}`(需 Bearer token,role=admin),依角色查詢指定用戶的基本資料與對應 profile。
|
||||
|
||||
#### Scenario: 查詢存在的用戶
|
||||
- **WHEN** 管理員以有效 id 查詢對應角色的用戶
|
||||
- **THEN** 回傳 HTTP 200 與該用戶資料
|
||||
|
||||
#### Scenario: id 不存在或角色不符
|
||||
- **WHEN** 查詢的 id 不存在,或該用戶角色與端點不符(如以 check-member 查 provider)
|
||||
- **THEN** 回傳 HTTP 404
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Auth 登入/註冊/登出測試覆蓋(三角色)
|
||||
(僅列出本次變更的 scenario;其餘 scenario 不變)
|
||||
|
||||
原「Admin 註冊成功」與「Admin 重複 Email 註冊失敗」兩個 scenario 移除(端點已刪),改為:
|
||||
|
||||
#### Scenario: Admin 公開註冊端點保持關閉
|
||||
- **WHEN** 任何人送出 `POST /api/admin/register`(該端點已於 2026-06-11 因 P0 漏洞移除,見 admin-auth 規格「管理員帳號建立途徑」)
|
||||
- **THEN** 回傳 HTTP 404,且不建立任何帳號;帳號建立改由 `app:create-admin` command 覆蓋測試
|
||||
|
||||
另補主規格歸檔時遺留的 TBD Purpose:
|
||||
|
||||
> 驗證三角色(member / provider / admin)認證流程的測試覆蓋契約:登入/註冊/登出、帳號鎖定(P2)、OAuth state 驗證、token refresh 與登入頻率限制。此規格定義測試套件必須覆蓋的場景,任何認證行為變更時,對應測試必須同步失敗以攔截回歸。
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 登入頻率限制
|
||||
後端 SHALL 對所有登入端點套用 IP-based 頻率限制,超過限制時回傳 HTTP 429。Member 與 Provider 每 IP 每分鐘最多 10 次(原定 5 次;帳號鎖定機制上線後已涵蓋暴力破解防護,放寬以容納共享 IP 場景,與實作 `throttle:10,1` 一致);Admin 因影響範圍更廣,限制為每 IP 每分鐘最多 3 次。
|
||||
|
||||
#### Scenario: Member / Provider 正常登入不受影響
|
||||
- **WHEN** 同一 IP 在 1 分鐘內對 `/api/member/login` 或 `/api/provider/login` 送出 10 次以內的請求
|
||||
- **THEN** 請求正常處理,回傳對應的登入結果(200 成功或 401 失敗)
|
||||
|
||||
#### Scenario: Member / Provider 超過頻率限制
|
||||
- **WHEN** 同一 IP 在 1 分鐘內送出第 11 次 member 或 provider 登入請求
|
||||
- **THEN** 回傳 HTTP 429,並帶有 `Retry-After` header 指示等待時間
|
||||
@@ -0,0 +1,12 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 專案基礎建設
|
||||
前端 SHALL 建立於本 repo 的 `frontend/` 目錄(原規劃獨立 repo,後併入主 repo 以簡化版控與部署),使用 Vue 3 + Vite + Tailwind CSS + Vue Router 4 + Pinia + Axios,並設定 `.env` 指定後端 API base URL。
|
||||
|
||||
#### Scenario: 開發環境啟動
|
||||
- **WHEN** 開發者執行 `npm run dev`
|
||||
- **THEN** 應用在 `http://localhost:5173` 啟動,無編譯錯誤
|
||||
|
||||
#### Scenario: API base URL 設定
|
||||
- **WHEN** `.env` 中設定 `VITE_API_URL=http://localhost:80`
|
||||
- **THEN** 所有 Axios 請求以此為 base URL
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# provider-verification Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
定義 `provider_profiles.is_verified` 的最小業務語意:未通過平台驗證的教練,其課程不對公開端點曝光。在完整教練審核流程(證照上傳、審核佇列、駁回原因)實作前,先以此約束堵住「未審核教練可公開曝光」的風險。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 未驗證教練的課程不對公開端點曝光
|
||||
公開課程端點(`GET /api/diving-offers`、`GET /api/diving-offers/{id}`)SHALL 僅回傳符合以下任一條件的課程:(a) `provider_id` 為 null(平台自有資料);(b) 課程所屬 Provider 的 `provider_profiles.is_verified = true`。未驗證教練的課程在列表中 SHALL 被排除,在詳情端點 SHALL 回傳 404。
|
||||
|
||||
#### Scenario: 已驗證教練的課程正常曝光
|
||||
- **WHEN** 匿名使用者請求 `GET /api/diving-offers`
|
||||
- **THEN** 已驗證教練(is_verified=true)的課程出現在結果中
|
||||
|
||||
#### Scenario: 未驗證教練的課程從列表排除
|
||||
- **WHEN** 匿名使用者請求 `GET /api/diving-offers`
|
||||
- **THEN** 未驗證教練(is_verified=false 或無 ProviderProfile)的課程不出現在結果中
|
||||
|
||||
#### Scenario: 未驗證教練的課程詳情回 404
|
||||
- **WHEN** 匿名使用者請求 `GET /api/diving-offers/{id}`,該課程屬於未驗證教練
|
||||
- **THEN** 回傳 HTTP 404
|
||||
|
||||
#### Scenario: provider_id 為 null 的課程不受限
|
||||
- **WHEN** 匿名使用者請求公開課程端點,課程的 `provider_id` 為 null
|
||||
- **THEN** 課程正常曝光
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 驗證狀態切換立即生效
|
||||
管理員透過 `PUT /api/admin/providers/{id}/toggle-verified` 切換驗證狀態後,公開課程列表的快取(`diving_offers` cache tag)SHALL 立即失效,下次請求反映最新可見性。
|
||||
|
||||
#### Scenario: 取消驗證後課程立即從公開列表消失
|
||||
- **WHEN** 管理員將教練 is_verified 由 true 切為 false
|
||||
- **THEN** 下一次 `GET /api/diving-offers` 請求不包含該教練的課程(不受 180 秒快取影響)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 教練自有管理端點不受可見性限制
|
||||
Provider 對自己課程的管理端點(`/api/provider/offers*`)與 Admin 管理端點(`/api/admin/offers*`)SHALL 不受公開可見性過濾影響,未驗證教練仍可登入、編輯與管理自己的課程。
|
||||
|
||||
#### Scenario: 未驗證教練仍可管理自己的課程
|
||||
- **WHEN** 未驗證教練以有效 token 請求 `GET /api/provider/offers`
|
||||
- **THEN** 回傳該教練的全部課程
|
||||
|
||||
## Notes
|
||||
|
||||
已知限制(留待完整教練審核流程處理):`GET /api/diving-offers/{id}/schedules`、`GET /api/diving-offers/{id}/reviews` 與預約建立流程尚未套用相同過濾,知道課程 id 的使用者仍可間接互動。完整審核流程(verification_status enum、證照上傳、審核佇列)見 `docs/analysis/2026-06-11-future-roadmap-feasibility.md` §2.1。
|
||||
@@ -0,0 +1,42 @@
|
||||
## 1. T1.1 封鎖 admin/register(P0)
|
||||
|
||||
- [x] 1.1 移除 `routes/api.php` 的公開 `POST /api/admin/register` 路由
|
||||
- [x] 1.2 移除 `AuthController::registerAdmin()` 方法
|
||||
- [x] 1.3 移除 `app/Docs/AuthApiDoc.php` 對應 Swagger 區塊
|
||||
- [x] 1.4 新增 `app/Console/Commands/CreateAdminUser.php`(`app:create-admin`,密碼 min:8,支援 --position / --department,未帶 --password 時互動式輸入)
|
||||
- [x] 1.5 [整合測試] `AdminAccountCreationTest`:端點回 404 不建帳號、command 建立 admin+profile、重複 email 失敗、弱密碼失敗(4 案例)
|
||||
- [x] 1.6 更新 `admin-auth` 規格:新增「管理員帳號建立途徑」requirement
|
||||
- [x] 1.7 確認前端無 admin 註冊入口(無引用)、Seeder 不受影響
|
||||
|
||||
## 2. T1.2 同步 login-rate-limiting 規格
|
||||
|
||||
- [x] 2.1 規格門檻 5/min → 10/min(與 `throttle:10,1` 實作及 AuthRateLimitTest 一致),註記放寬理由
|
||||
|
||||
## 3. T2.1 is_verified 最小業務語意(P1)
|
||||
|
||||
- [x] 3.1 `DivingOffer` 新增 `scopeVisibleToPublic`(provider_id null 或教練已驗證)
|
||||
- [x] 3.2 `DivingOfferController` 公開 index / show 套用 scope
|
||||
- [x] 3.3 `AdminUserController::toggleProviderVerified` 後 flush `diving_offers` 快取 tag
|
||||
- [x] 3.4 新增 `provider-verification` 規格(含已知限制 Notes)
|
||||
- [x] 3.5 [整合測試] `DivingOfferVisibilityTest`:列表含已驗證/排除未驗證/null 不受限、詳情 404、toggle 立即生效(快取失效)、教練自有端點不受限(7 案例)
|
||||
|
||||
## 4. T3.1 預約核心測試(P2)
|
||||
|
||||
- [x] 4.1 [整合測試] `BookingLifecycleTest`:狀態機合法/非法轉移、pending 不佔名額、價格快照、24h 取消截止、名額釋放、跨用戶授權邊界(17 案例)
|
||||
- [x] 4.2 [整合測試] `BookingOversellTest`:confirm 時防超賣不變式、full 擋新預約、釋放後可再確認(3 案例)
|
||||
- [x] 4.3 [整合測試] `BookingSchedulerTest`:`app:expire-pending-bookings` 48h 邊界、`app:complete-finished-bookings` 日期邊界與通知(6 案例)
|
||||
- [x] 4.4 [整合測試] `BookingChatAuthTest`:HTTP 端點與 presence channel 雙防線(8 案例)
|
||||
- [x] 4.5 [整合測試] `AdminEndpointAuthTest`:六個 admin GET 端點 401/403/200 矩陣、Admin 不可繞過狀態機(5 案例)
|
||||
|
||||
## 5. T4 規格清理
|
||||
|
||||
- [x] 5.1 補 `auth-test-coverage` 歸檔遺留的 TBD Purpose
|
||||
- [x] 5.2 `auth-test-coverage` admin register 場景改寫為「公開註冊端點保持關閉」
|
||||
- [x] 5.3 同步移除 `AuthLoginTest` 中測已刪端點的 2 個測試
|
||||
- [x] 5.4 修正 `member-portal-ui` 前端 repo 描述(實際位於本 repo `frontend/`)
|
||||
- [x] 5.5 `admin-auth` 補 check-member / check-provider 查詢端點規格
|
||||
|
||||
## 6. 驗證
|
||||
|
||||
- [x] 6.1 容器內 `php artisan test` 全綠:146 passed / 378 assertions
|
||||
- [x] 6.2 確認本機 CourseImageTest 失敗為環境因素(laragon PHP 缺 GD),非回歸
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-06-11
|
||||
@@ -0,0 +1,31 @@
|
||||
# Design — course-image-compression
|
||||
|
||||
## D1:壓縮參數與聊天管線完全一致
|
||||
|
||||
`scaleDown(2048×2048,僅縮不放大) + toJpeg(quality: 85)`。理由:聊天圖片已用此參數運行無客訴,平台內影像品質一致;2048px 足夠 Retina 全螢幕顯示,quality 85 是檔案大小與畫質的常見甜蜜點(5MB 手機照通常壓到 300~600KB)。
|
||||
|
||||
不做多尺寸縮圖(列表 600px 版本):Simplicity First——先用單一尺寸 + 前端 lazy load 解決主要痛點,若日後列表頁仍慢再開 change 加 responsive 尺寸。
|
||||
|
||||
## D2:共用邏輯抽 trait、聊天端暫不重構
|
||||
|
||||
壓縮邏輯在聊天與課程兩處重複,抽 `App\Traits\CompressesImages::compressToJpeg(UploadedFile, string $directory): string`(回傳儲存路徑)。本 change 只讓 `CourseImageController` 使用;`BookingMessageController` 改用 trait 屬行為等價重構,但該處**無測試覆蓋**,依 Rule 3(surgical)不在本 change 動,列為後續清理項。
|
||||
|
||||
選 trait 而非 Service class:專案已有 `app/Traits/` 慣例、無 `app/Services/`;單一方法的無狀態工具用 trait 侵入最小。
|
||||
|
||||
## D3:一律轉存 .jpg 的取捨
|
||||
|
||||
- PNG 透明 → GD toJpeg 轉為實色背景。課程圖片皆為實拍照片,透明場景不存在,可接受
|
||||
- 副檔名統一 `.jpg`:儲存路徑由 `store()` 隨機檔名改為 `Storage::put` + uuid 檔名(與聊天一致),舊圖路徑不受影響(DB 存相對路徑,URL accessor 不變)
|
||||
- 既有舊圖不回溯處理:數量少且會隨教練更新自然汰換;回溯腳本是過度工程
|
||||
|
||||
## D4:驗證上限 10MB 的位置
|
||||
|
||||
維持 Laravel validation `max:10240`(與聊天一致)。壓縮在 validation 之後、儲存之前,記憶體峰值由 `memory_limit=512M`(local.ini)涵蓋——10MB JPEG 解碼後約 2048×2048×4bytes ≈ 16MB 級別,安全。
|
||||
|
||||
## D5:測試策略
|
||||
|
||||
既有 `CourseImageTest` 14 案例的調整面:
|
||||
- 「upload cover too large」:5MB → 改為 >10MB 才 422;新增 5MB 通過案例
|
||||
- 路徑斷言:`offers/{id}/cover/` 目錄不變,副檔名改 `.jpg`
|
||||
- 新增:>2048px 的 fake 圖上傳後實際尺寸 ≤2048(用 Intervention 讀回驗證)
|
||||
- 測試必須在容器內跑(本機 laragon PHP 缺 GD)
|
||||
@@ -0,0 +1,26 @@
|
||||
## Why
|
||||
|
||||
課程封面與圖庫圖片目前**原檔直存**(`CourseImageController` 只驗證格式與 2MB 上限,無任何處理),而聊天圖片已有成熟的壓縮管線(`scaleDown(2048) + toJpeg(85)`,`BookingMessageController.php:115`)。後果:
|
||||
|
||||
1. 課程列表/詳情頁直接載入未壓縮原圖,頁面重量大、行動網路下體感慢(效能優化計畫 O3.1,`docs/analysis/2026-06-11-performance-optimization-plan.md` §Phase 3)
|
||||
2. 2MB 上限對手機拍攝原圖(5~10MB)太嚴格,教練必須自行縮圖才能上傳——體驗差且實務上常失敗
|
||||
|
||||
## What Changes
|
||||
|
||||
- `CourseImageController` 的 `uploadCover` 與 `uploadImage` 套用與聊天圖片一致的壓縮管線:超過 2048px 等比縮小、轉存 JPEG quality 85
|
||||
- 上傳大小上限 2MB → **10MB**(與聊天圖片一致;反正入庫前會壓縮)
|
||||
- 壓縮邏輯抽共用 trait `App\Traits\CompressesImages`(聊天與課程兩處重複,依維護性規則抽共用;聊天端改用 trait 留待後續——該處無測試覆蓋,不在本 change 冒險重構)
|
||||
- 前端課程列表/詳情的 `<img>` 加 `loading="lazy"`
|
||||
- 更新 `CourseImageTest` 既有案例(儲存副檔名、大小上限)並新增壓縮行為測試
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `course-image-upload`:上傳大小上限 2MB→10MB;新增「伺服器端壓縮」requirement(縮圖、JPEG 轉存、尺寸上限 2048px)
|
||||
|
||||
## Impact
|
||||
|
||||
- 影響檔案:`CourseImageController`、新增 `app/Traits/CompressesImages.php`、`tests/Feature/CourseImageTest.php`、前端 `CoursesView.vue` / `CourseDetailView.vue`
|
||||
- 行為變更:儲存檔案一律為 `.jpg`(PNG 透明區域轉為實色背景——課程照片皆為實拍照,可接受,見 design);既有已上傳的舊圖不處理(數量少,自然汰換)
|
||||
- 依賴:Intervention Image v3 + GD 已安裝(聊天功能在用);測試需在容器內跑(本機 PHP 缺 GD)
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 伺服器端圖片壓縮
|
||||
系統 SHALL 在儲存課程封面與相簿圖片前進行壓縮:長邊超過 2048px 時等比縮小至 2048px 內(僅縮不放大),一律轉存 JPEG(quality 85,副檔名 `.jpg`,uuid 檔名)。與聊天圖片管線(`scaleDown(2048) + toJpeg(85)`)參數一致。
|
||||
|
||||
#### Scenario: 大圖縮小
|
||||
- **WHEN** Provider 上傳長邊 > 2048px 的圖片
|
||||
- **THEN** 儲存的檔案長邊 ≤ 2048px,格式為 JPEG
|
||||
|
||||
#### Scenario: 小圖不放大
|
||||
- **WHEN** Provider 上傳長邊 ≤ 2048px 的圖片
|
||||
- **THEN** 儲存的檔案維持原尺寸,格式轉為 JPEG
|
||||
|
||||
#### Scenario: PNG/WebP 轉存 JPEG
|
||||
- **WHEN** Provider 上傳 png 或 webp 格式圖片
|
||||
- **THEN** 儲存的檔案為 `.jpg`,回傳的 URL 指向轉存後檔案
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Provider 上傳課程封面
|
||||
Provider SHALL 能為自己的課程上傳一張封面圖片,新上傳會覆蓋舊封面並刪除舊實體檔案。上傳大小上限為 10MB(原 2MB;伺服器端會壓縮,放寬以容納手機原圖)。
|
||||
|
||||
#### Scenario: 成功上傳封面
|
||||
- **WHEN** Provider 送出 `POST /api/provider/offers/{id}/cover`,包含 `image` 檔案(jpeg/png/webp,≤10MB)
|
||||
- **THEN** 系統壓縮後儲存至 `public` disk 的 `offers/{offer_id}/cover/` 目錄(`.jpg`),更新 `diving_offers.cover_image`,回傳 `cover_image_url`
|
||||
|
||||
#### Scenario: 檔案大小驗證
|
||||
- **WHEN** 上傳檔案超過 10MB(10240KB)
|
||||
- **THEN** 系統回傳 422
|
||||
|
||||
#### Scenario: 10MB 以內的手機原圖可直接上傳
|
||||
- **WHEN** 上傳 2MB~10MB 之間的圖片(原規格會拒絕)
|
||||
- **THEN** 系統接受並壓縮儲存
|
||||
|
||||
### Requirement: Provider 上傳課程相簿圖片
|
||||
Provider SHALL 能為課程上傳最多 3 張相簿圖片(格式與大小限制同封面:jpeg/png/webp、≤10MB,壓縮後存為 `.jpg`),sort_order 不連續為預期行為(刪除後重新上傳序號接續最大值)。
|
||||
|
||||
#### Scenario: 成功上傳相簿圖片
|
||||
- **WHEN** Provider 送出 `POST /api/provider/offers/{id}/images`,包含 `image` 檔案,且目前相簿圖片數 < 3
|
||||
- **THEN** 系統壓縮後儲存至 `offers/{offer_id}/gallery/` 目錄(`.jpg`),建立 `course_images` 紀錄,`sort_order = MAX(sort_order) + 1`,回傳新圖片資訊
|
||||
@@ -0,0 +1,23 @@
|
||||
## 1. 共用壓縮 trait
|
||||
|
||||
- [x] 1.1 新增 `app/Traits/CompressesImages.php`:`compressToJpeg(UploadedFile $file, string $directory): string`——Intervention v3 讀取 → 長邊 >2048 時 `scaleDown(2048, 2048)` → `toJpeg(quality: 85)` → `Storage::disk('public')->put("{$directory}/{uuid}.jpg", ...)`,回傳相對路徑
|
||||
|
||||
## 2. CourseImageController 套用
|
||||
|
||||
- [x] 2.1 `validateImage()`:`max:2048` → `max:10240`
|
||||
- [x] 2.2 `uploadCover()`:`store()` 改用 `compressToJpeg($file, "offers/{$offerId}/cover")`
|
||||
- [x] 2.3 `uploadImage()`:同上,目錄 `offers/{$offerId}/gallery`
|
||||
|
||||
## 3. 前端 lazy loading
|
||||
|
||||
- [x] 3.1 `frontend/src/views/CoursesView.vue` 與 `CourseDetailView.vue` 的課程圖 `<img>` 加 `loading="lazy"`(其他列表元件若有課程圖一併處理)
|
||||
|
||||
## 4. 測試
|
||||
|
||||
- [x] 4.1 更新 `CourseImageTest`:大小上限案例改 >10MB 422、新增 5MB 通過案例、儲存路徑斷言改 `.jpg`
|
||||
- [x] 4.2 新增壓縮行為案例:上傳 3000×3000 fake 圖 → 讀回實際尺寸 ≤2048;上傳 PNG → 儲存為 `.jpg`
|
||||
- [x] 4.3 容器內 `php artisan test` 全綠(基準:151 passed;本機缺 GD 不可作準)
|
||||
|
||||
## 5. 規格同步
|
||||
|
||||
- [x] 5.1 將本 change 的 specs 增量套用至主規格 `openspec/specs/course-image-upload/spec.md`
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-06-11
|
||||
@@ -0,0 +1,26 @@
|
||||
# Design — provider-verification-gaps
|
||||
|
||||
## D1:預約入口擋在哪一層
|
||||
|
||||
候選:
|
||||
- (a) `MemberBookingController::store` 的 Layer 1 快速失敗區(採用)——載入 `$schedule->divingOffer.provider.providerProfile` 後檢查,與既有「時段不開放」「名額不足」的 422 錯誤同層、同風格
|
||||
- (b) `CourseSchedule` 加 scope——過度設計,時段本身沒有可見性概念,可見性屬於課程
|
||||
|
||||
回應訊息建議與既有風格一致:`['status' => false, 'message' => '此課程目前不開放預約']`,HTTP 422。不用 404:schedule id 是真實存在的資源,422 表達「請求合法但業務上不可行」。
|
||||
|
||||
## D2:既有預約不受影響(關鍵政策決定)
|
||||
|
||||
教練被取消驗證(toggle off)時:
|
||||
- **新預約**:擋(本 change 範圍)
|
||||
- **既有 pending**:教練仍可 confirm/reject——合約協商已開始,平台不單方面撕毀;若要更嚴格(pending 也凍結),屬產品決策,本 change 不做
|
||||
- **既有 confirmed / completed**:聊天、完課、評價全部照常——會員已付出時間成本,懲罰應落在教練(不能接新單)而非會員
|
||||
|
||||
此政策需寫入規格 scenario,避免未來誤解為漏洞。
|
||||
|
||||
## D3:visibleToPublic 的查詢成本
|
||||
|
||||
`schedules` / `reviews` publicList 由 `findOrFail($offerId)` 改為 `visibleToPublic()->findOrFail($offerId)`,多一個 `whereExists` 子查詢(provider→providerProfile),單筆 by-id 查詢成本可忽略;兩端點目前無快取,無一致性問題。`store` 的檢查在 transaction 之外的 Layer 1 即可(教練驗證狀態變動頻率極低,不需要鎖)。
|
||||
|
||||
## D4:測試歸屬
|
||||
|
||||
擴充既有 `DivingOfferVisibilityTest`(同一保護目標:可見性),不另開檔案;預約入口的測試放 `BookingLifecycleTest`(屬建立預約的前置條件),各加 3~4 案例。
|
||||
@@ -0,0 +1,30 @@
|
||||
## Why
|
||||
|
||||
2026-06-11 修補(archive: `2026-06-11-audit-remediation`)讓未驗證教練的課程從公開 `GET /api/diving-offers`(列表/詳情)消失,但 `provider-verification` 規格 Notes 載明三個遺留缺口:
|
||||
|
||||
1. `GET /api/diving-offers/{id}/schedules`(公開時段列表)未過濾——知道課程 id 仍可查到可預約時段
|
||||
2. `GET /api/diving-offers/{id}/reviews`(公開評價列表)未過濾——隱藏課程的評價仍可讀取
|
||||
3. `POST /api/member/bookings` 未檢查——會員可直接以 `schedule_id` 預約未驗證教練的課程,繞過整個可見性機制
|
||||
|
||||
缺口 3 最關鍵:可見性過濾的目的(平台對教練資質把關)在預約入口被完全繞過。
|
||||
|
||||
## What Changes
|
||||
|
||||
- `ScheduleController::publicList`:`DivingOffer::findOrFail` 改為 `DivingOffer::visibleToPublic()->findOrFail`,隱藏課程的時段查詢回 404
|
||||
- `ReviewController::publicList`:同上,隱藏課程的評價查詢回 404
|
||||
- `MemberBookingController::store`:建立預約前驗證 schedule 所屬課程對該會員「可預約」(課程屬已驗證教練或 `provider_id` 為 null),否則回 422
|
||||
- **既有預約不受影響**:教練被取消驗證時,已成立的 pending / confirmed / completed 預約照常運作(含聊天、完課、評價)——只擋新預約,不毀既有合約
|
||||
- 補充 Feature tests 並更新 `provider-verification` 規格(Notes 移除、改為正式 Requirements)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `provider-verification`:新增「公開子端點套用相同可見性」與「未驗證教練課程不可建立新預約」requirements;移除 Notes 中的已知限制聲明
|
||||
|
||||
## Impact
|
||||
|
||||
- 影響檔案:`ScheduleController`、`ReviewController`、`MemberBookingController`、`tests/Feature/DivingOfferVisibilityTest.php`(擴充)或新增測試檔
|
||||
- 行為變更:DemoSeeder 未驗證教練的課程時段/評價將不可公開查詢、不可新預約
|
||||
- 風險:低——`visibleToPublic` scope 已有 7 條測試保護,本 change 僅擴大套用範圍
|
||||
- 不影響:教練自有管理端點(`/api/provider/*`)、Admin 端點、既有預約的聊天與評價流程
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### 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 / completed)SHALL 不受教練驗證狀態變動影響:教練仍可處理 pending、confirmed 的聊天與完課流程照常、completed 可正常評價。
|
||||
|
||||
#### Scenario: 未驗證教練課程的新預約被拒絕
|
||||
- **WHEN** 會員以未驗證教練課程的 schedule_id 送出預約
|
||||
- **THEN** 回傳 HTTP 422,`{ status: false, message: "此課程目前不開放預約" }`,不建立 Booking
|
||||
|
||||
#### Scenario: 教練被取消驗證後既有預約照常
|
||||
- **WHEN** 教練在預約 confirmed 之後被取消驗證
|
||||
- **THEN** 該預約的聊天、完課、評價流程照常運作;僅新預約被擋
|
||||
@@ -0,0 +1,19 @@
|
||||
## 1. 公開子端點套用可見性
|
||||
|
||||
- [x] 1.1 `ScheduleController::publicList`:`DivingOffer::findOrFail($offerId)` → `DivingOffer::visibleToPublic()->findOrFail($offerId)`
|
||||
- [x] 1.2 `ReviewController::publicList`:同 1.1
|
||||
- [x] 1.3 [整合測試] `DivingOfferVisibilityTest` 擴充:未驗證教練課程的 `/schedules` 回 404、`/reviews` 回 404、已驗證教練兩端點正常(3~4 案例)
|
||||
|
||||
## 2. 預約入口檢查
|
||||
|
||||
- [x] 2.1 `MemberBookingController::store` Layer 1 增加課程可預約檢查(provider_id null 或教練已驗證),不符回 422 `此課程目前不開放預約`
|
||||
- [x] 2.2 [整合測試] `BookingLifecycleTest` 擴充:未驗證教練課程的 schedule 不可建立預約(422)、已驗證可預約、教練被取消驗證後既有 confirmed 預約聊天/完課照常(3 案例)
|
||||
|
||||
## 3. 規格同步
|
||||
|
||||
- [x] 3.1 `openspec/specs/provider-verification/spec.md`:新增「公開子端點套用相同可見性」「未驗證教練課程不可建立新預約」requirements(含既有預約不受影響 scenario),移除 Notes 已知限制段落
|
||||
|
||||
## 4. 驗證
|
||||
|
||||
- [x] 4.1 容器內 `php artisan test` 全綠(基準:146 passed)
|
||||
- [x] 4.2 手動驗證 DemoSeeder 未驗證教練課程:時段/評價查詢 404、無法新預約
|
||||
@@ -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 案例零 regression(Unit test 已驗證)
|
||||
+146
@@ -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 201,body 包含 `{ 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 403,body 包含 `{ status: false }`
|
||||
|
||||
#### Scenario: Provider 刪除自己的課程
|
||||
- **WHEN** 已認證的 Provider 送出 `DELETE /api/provider/offers/{id}`(該課程屬於此 Provider)
|
||||
- **THEN** 回傳 HTTP 200,body 包含 `{ 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 201,body 包含 `{ 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 200,DB 記錄已更新
|
||||
|
||||
#### 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`(記錄保留,不刪除);已是終態的 booking(completed/expired/已取消)不受影響
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Admin 使用者管理測試覆蓋
|
||||
測試套件 SHALL 驗證 Admin 查詢會員/教練列表、切換帳號啟用狀態、切換教練驗證狀態的正確行為。
|
||||
|
||||
#### Scenario: Admin 取得會員列表
|
||||
- **WHEN** Admin 送出 `GET /api/admin/members`
|
||||
- **THEN** 回傳 HTTP 200,data 陣列只包含 role=member 的使用者,含 meta 分頁資訊
|
||||
|
||||
#### Scenario: Admin 取得教練列表
|
||||
- **WHEN** Admin 送出 `GET /api/admin/providers`
|
||||
- **THEN** 回傳 HTTP 200,data 陣列只包含 role=provider 的使用者
|
||||
|
||||
#### Scenario: Admin 切換會員帳號啟用狀態
|
||||
- **WHEN** Admin 送出 `POST /api/admin/members/{id}/toggle-active`(目標會員 is_active=true)
|
||||
- **THEN** 回傳 HTTP 200,DB 記錄 is_active 由 true 變為 false
|
||||
|
||||
#### Scenario: Admin 切換教練驗證狀態並清除快取
|
||||
- **WHEN** Admin 送出 `POST /api/admin/providers/{id}/toggle-verified`(目標教練 is_verified=false)
|
||||
- **THEN** 回傳 HTTP 200,provider_profiles.is_verified 由 false 變為 true,diving_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 id;Member B 的 booking id 不在 data 中
|
||||
|
||||
#### Scenario: Provider 只能看到屬於自己課程的預約
|
||||
- **WHEN** Provider A 呼叫 `GET /api/provider/bookings`,系統中有屬於 Provider A 課程與 Provider B 課程各一筆預約
|
||||
- **THEN** 回傳 data 中只包含 Provider A 課程的 booking id;Provider B 課程的 booking id 不在 data 中
|
||||
|
||||
#### Scenario: Admin 可查看所有預約
|
||||
- **WHEN** Admin 呼叫 `GET /api/admin/bookings`,系統中有 2 筆不同 Member 的預約
|
||||
- **THEN** 回傳 data 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::create,is_verified=true)
|
||||
- `makeOffer(User $provider): DivingOffer`(DivingOffer::create 最小必填欄位)
|
||||
- [x] 1.2 [整合測試] `test_provider_creates_offer_successfully`:POST 201,data.provider_id = auth provider id,DB 存在
|
||||
- [x] 1.3 [整合測試] `test_create_offer_missing_required_field_returns_422`:缺 title 送出,回傳 422(代表性一個欄位即可)
|
||||
- [x] 1.4 [整合測試] `test_provider_updates_own_offer`:PUT 200,DB 欄位已變更
|
||||
- [x] 1.5 [整合測試] `test_provider_cannot_update_others_offer`:Provider B 更新 Provider A 的課程,403
|
||||
- [x] 1.6 [整合測試] `test_provider_deletes_own_offer`:DELETE 200,DB 記錄不存在
|
||||
- [x] 1.7 [整合測試] `test_provider_cannot_delete_others_offer`:403
|
||||
- [x] 1.8 [整合測試] `test_unauthenticated_request_is_rejected`:無 token POST,401
|
||||
|
||||
## 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 201,status=open
|
||||
- [x] 2.3 [整合測試] `test_provider_cannot_create_schedule_for_others_offer`:403
|
||||
- [x] 2.4 [整合測試] `test_past_date_returns_422`:scheduled_date = yesterday,422(代表性驗證案例)
|
||||
- [x] 2.5 [整合測試] `test_provider_updates_schedule_max_participants`:PUT 200,DB 更新
|
||||
- [x] 2.6 [整合測試] `test_max_participants_below_current_returns_422`:current_participants=3,PUT max_participants=2,422
|
||||
- [x] 2.7 [整合測試] `test_provider_cannot_update_others_schedule`:403
|
||||
- [x] 2.8 [整合測試] `test_delete_schedule_marks_active_bookings_as_provider_cancelled`:建立一筆 pending + 一筆 confirmed booking,DELETE 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 actingAs,GET /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 + schedule(open, 未來日期) + 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 各有一筆 booking,actingAs ProviderA → `GET /api/provider/bookings`,斷言 data 中只含 ProviderA schedule 的 booking id
|
||||
- [x] 5.4 [整合測試] `test_admin_sees_all_bookings`:建立 2 筆不同 member 的 booking,actingAs 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:端點設計
|
||||
|
||||
**Provider(auth: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
|
||||
|
||||
**Admin(auth: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 channel,try/catch 包裹不阻斷主流程)。送審時**不**通知 Admin(Admin 有佇列頁;避免通知氾濫),列為未來選項。
|
||||
|
||||
## D6:前端
|
||||
|
||||
- **教練端** `views/coach/VerificationView.vue`(route `/coach/verification`):狀態卡(四態 + 駁回原因)、證照上傳/刪除(沿用 OfferFormView 的圖片上傳模式)、送審按鈕;CoachLayout 側欄加入口與狀態 badge
|
||||
- **Admin 端** `views/admin/ProvidersView.vue` 擴充:狀態 badge 四態化、「待審核」filter、展開查看證照圖、通過/駁回操作(駁回原因用 inline 輸入,沿用該頁既有操作風格);不另開新頁(教練列表即審核入口,避免兩頁資料重複)
|
||||
|
||||
## D7:Demo 資料與測試遷移
|
||||
|
||||
- DemoSeeder:3 位已驗證 → `approved`;1 位未驗證 → 給 1 張證照 + `pending`(展示審核佇列)
|
||||
- 既有測試 helper(DivingOfferVisibility / 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 規格既定政策)
|
||||
+14
@@ -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)
|
||||
+84
@@ -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、reviews)SHALL 僅曝光 `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` accessor(appends,= 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→rejected,reason 必填 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`(寫檔案)
|
||||
- Scheduler:cron 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 mount(P0 待辦)
|
||||
- 不動任何 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
|
||||
|
||||
### D1:Scheduler 用 `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
|
||||
```
|
||||
|
||||
### D2:Log 改 `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`)。
|
||||
|
||||
### D3:Session 改 Redis,不需建新資料表
|
||||
|
||||
Redis 已在跑(`cfdive-redis`),`config/session.php` 讀 `.env`,開箱即用。`sessions` table 保留不刪(避免 rollback 需要重建)。
|
||||
|
||||
### D4:VPS `.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
|
||||
+27
@@ -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 使用 Redis,queue 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` 改完須清 cache(rebuild 時 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
|
||||
|
||||
### D1:healthcheck 用 `nc -z 127.0.0.1 9000` 檢查 PHP-FPM port
|
||||
|
||||
**為什麼不用 `/health` HTTP 端點:**
|
||||
app container 跑的是 PHP-FPM(FastCGI,port 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-wget(Alpine),兩者環境不同,不可混用。
|
||||
|
||||
**選定方案:**
|
||||
```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,但不驗證 Nginx;Nginx 有自己的獨立 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
|
||||
+32
@@ -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 0,container 標記為 healthy
|
||||
|
||||
#### Scenario: PHP-FPM 未啟動時 healthcheck 失敗
|
||||
- **WHEN** `app` container 啟動但 PHP-FPM 程序尚未就緒(port 9000 未開)
|
||||
- **THEN** `nc -z 127.0.0.1 9000` 回傳非 0 exit code,container 維持 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 或資源
|
||||
+19
@@ -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 使用 Redis,queue 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`,確認 phpmyadmin(8081)與 mailpit(8025)仍可正常存取
|
||||
- [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` 查看)
|
||||
@@ -1,5 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 管理員帳號建立途徑
|
||||
管理員帳號 SHALL 僅能透過主機端 `php artisan app:create-admin` command 或資料庫 seeder 建立。系統 MUST NOT 提供任何公開的管理員註冊 HTTP 端點(原 `POST /api/admin/register` 已於 2026-06-11 因 P0 安全漏洞移除)。command 建立的密碼門檻為至少 8 碼,高於一般使用者。
|
||||
|
||||
#### Scenario: 公開註冊端點保持關閉
|
||||
- **WHEN** 任何人(含未認證請求)送出 `POST /api/admin/register`
|
||||
- **THEN** 回傳 HTTP 404,且不建立任何帳號
|
||||
|
||||
#### Scenario: 主機端建立管理員成功
|
||||
- **WHEN** 操作者於主機執行 `php artisan app:create-admin {name} {email} --password={password}` 且資料合法
|
||||
- **THEN** 建立 role=admin 的 User 與對應 AdminProfile
|
||||
|
||||
#### Scenario: 密碼過弱或 email 重複
|
||||
- **WHEN** command 收到少於 8 碼的密碼或已存在的 email
|
||||
- **THEN** command 以失敗結束,不建立任何帳號
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 管理員登入
|
||||
後端 SHALL 提供 `POST /api/admin/login`(現有 AuthController 方法),驗證 email/password 並確認 role=admin,回傳有效期 7 天的 Bearer token。
|
||||
|
||||
@@ -35,6 +52,19 @@
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 管理員查詢指定用戶資料
|
||||
後端 SHALL 提供 `GET /api/admin/check-member/{id}` 與 `GET /api/admin/check-provider/{id}`(需 Bearer token,role=admin),依角色查詢指定用戶的基本資料與對應 profile。
|
||||
|
||||
#### Scenario: 查詢存在的用戶
|
||||
- **WHEN** 管理員以有效 id 查詢對應角色的用戶
|
||||
- **THEN** 回傳 HTTP 200 與該用戶資料
|
||||
|
||||
#### Scenario: id 不存在或角色不符
|
||||
- **WHEN** 查詢的 id 不存在,或該用戶角色與端點不符(如以 check-member 查 provider)
|
||||
- **THEN** 回傳 HTTP 404
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 管理員 Bearer Token 有效期
|
||||
後端 SHALL 發行有效期為 7 天的管理員 Bearer Token。主動使用 API 的 session 可透過 refresh 端點取得新 token(sliding window);閒置超過 7 天後需重新登入。
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# auth-test-coverage Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change auth-tests. Update Purpose after archive.
|
||||
驗證三角色(member / provider / admin)認證流程的測試覆蓋契約:登入/註冊/登出、帳號鎖定(P2)、OAuth state 驗證、token refresh 與登入頻率限制。此規格定義測試套件必須覆蓋的場景,任何認證行為變更時,對應測試必須同步失敗以攔截回歸。
|
||||
## Requirements
|
||||
### Requirement: Auth 登入/註冊/登出測試覆蓋(三角色)
|
||||
測試套件 SHALL 對 member、provider、admin 三個角色各自驗證以下場景,確保回歸時能即時偵測。
|
||||
@@ -66,13 +66,9 @@ TBD - created by archiving change auth-tests. Update Purpose after archive.
|
||||
- **WHEN** role=member 的帳號嘗試 `POST /api/provider/login`
|
||||
- **THEN** 回傳 HTTP 401,`{ status: false, message: "電子郵件或密碼錯誤" }`(查詢以 role 過濾,跨角色帳號視同不存在)
|
||||
|
||||
#### Scenario: Admin 註冊成功
|
||||
- **WHEN** 送出有效的 name / email / password / password_confirmation 至 `POST /api/admin/register`
|
||||
- **THEN** 回傳 HTTP 201,body 包含 `{ status: true, data: { user } }`,DB 存在對應 admin 用戶
|
||||
|
||||
#### Scenario: Admin 重複 Email 註冊失敗
|
||||
- **WHEN** 送出已存在的 email 至 `POST /api/admin/register`
|
||||
- **THEN** 回傳 HTTP 422
|
||||
#### Scenario: Admin 公開註冊端點保持關閉
|
||||
- **WHEN** 任何人送出 `POST /api/admin/register`(該端點已於 2026-06-11 因 P0 漏洞移除,見 admin-auth 規格「管理員帳號建立途徑」)
|
||||
- **THEN** 回傳 HTTP 404,且不建立任何帳號;帳號建立改由 `app:create-admin` command 覆蓋測試
|
||||
|
||||
#### Scenario: Admin 登入成功
|
||||
- **WHEN** 送出正確的 email / password 至 `POST /api/admin/login`
|
||||
|
||||
@@ -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 0,container 標記為 healthy
|
||||
|
||||
#### Scenario: PHP-FPM 未啟動時 healthcheck 失敗
|
||||
- **WHEN** `app` container 啟動但 PHP-FPM 程序尚未就緒(port 9000 未開)
|
||||
- **THEN** `nc -z 127.0.0.1 9000` 回傳非 0 exit code,container 維持 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 8025)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 --remove-orphans`
|
||||
- **THEN** `phpmyadmin` 與 `mailpit` 不啟動,不佔用任何 port 或資源
|
||||
@@ -1,9 +1,28 @@
|
||||
### Requirement: 伺服器端圖片壓縮
|
||||
系統 SHALL 在儲存課程封面與相簿圖片前進行壓縮:長邊超過 2048px 時等比縮小至 2048px 內(僅縮不放大),一律轉存 JPEG(quality 85,副檔名 `.jpg`,uuid 檔名)。與聊天圖片管線(`scaleDown(2048) + toJpeg(85)`)參數一致,共用 `App\Traits\CompressesImages`。
|
||||
|
||||
#### Scenario: 大圖縮小
|
||||
- **WHEN** Provider 上傳長邊 > 2048px 的圖片
|
||||
- **THEN** 儲存的檔案長邊 ≤ 2048px,格式為 JPEG
|
||||
|
||||
#### Scenario: 小圖不放大
|
||||
- **WHEN** Provider 上傳長邊 ≤ 2048px 的圖片
|
||||
- **THEN** 儲存的檔案維持原尺寸,格式轉為 JPEG
|
||||
|
||||
#### Scenario: PNG/WebP 轉存 JPEG
|
||||
- **WHEN** Provider 上傳 png 或 webp 格式圖片
|
||||
- **THEN** 儲存的檔案為 `.jpg`,回傳的 URL 指向轉存後檔案
|
||||
|
||||
### Requirement: Provider 上傳課程封面
|
||||
Provider SHALL 能為自己的課程上傳一張封面圖片,新上傳會覆蓋舊封面並刪除舊實體檔案。
|
||||
Provider SHALL 能為自己的課程上傳一張封面圖片,新上傳會覆蓋舊封面並刪除舊實體檔案。上傳大小上限為 10MB(原 2MB;伺服器端會壓縮,放寬以容納手機原圖)。
|
||||
|
||||
#### Scenario: 成功上傳封面
|
||||
- **WHEN** Provider 送出 `POST /api/provider/offers/{id}/cover`,包含 `image` 檔案(jpeg/png/webp,≤2MB)
|
||||
- **THEN** 系統儲存檔案至 `public` disk 的 `offers/{offer_id}/cover/` 目錄,更新 `diving_offers.cover_image`,回傳 `cover_image_url`
|
||||
- **WHEN** Provider 送出 `POST /api/provider/offers/{id}/cover`,包含 `image` 檔案(jpeg/png/webp,≤10MB)
|
||||
- **THEN** 系統壓縮後儲存至 `public` disk 的 `offers/{offer_id}/cover/` 目錄(`.jpg`),更新 `diving_offers.cover_image`,回傳 `cover_image_url`
|
||||
|
||||
#### Scenario: 10MB 以內的手機原圖可直接上傳
|
||||
- **WHEN** 上傳 2MB~10MB 之間的圖片(原規格會拒絕)
|
||||
- **THEN** 系統接受並壓縮儲存
|
||||
|
||||
#### Scenario: 覆蓋封面時刪除舊檔
|
||||
- **WHEN** Provider 上傳新封面,且原本已有封面
|
||||
@@ -14,7 +33,7 @@ Provider SHALL 能為自己的課程上傳一張封面圖片,新上傳會覆
|
||||
- **THEN** 系統回傳 422
|
||||
|
||||
#### Scenario: 檔案大小驗證
|
||||
- **WHEN** 上傳檔案超過 2MB(2048KB)
|
||||
- **WHEN** 上傳檔案超過 10MB(10240KB)
|
||||
- **THEN** 系統回傳 422
|
||||
|
||||
#### Scenario: 不可上傳他人課程的封面
|
||||
@@ -36,8 +55,8 @@ Provider SHALL 能刪除自己課程的封面,同步移除實體檔案。
|
||||
Provider SHALL 能為課程上傳最多 3 張相簿圖片,sort_order 不連續為預期行為(刪除後重新上傳序號接續最大值)。
|
||||
|
||||
#### Scenario: 成功上傳相簿圖片
|
||||
- **WHEN** Provider 送出 `POST /api/provider/offers/{id}/images`,包含 `image` 檔案(格式與大小同封面限制),且目前相簿圖片數 < 3
|
||||
- **THEN** 系統儲存檔案至 `offers/{offer_id}/gallery/` 目錄,建立 `course_images` 紀錄,`sort_order = MAX(sort_order) + 1`,回傳新圖片資訊
|
||||
- **WHEN** Provider 送出 `POST /api/provider/offers/{id}/images`,包含 `image` 檔案(格式與大小同封面限制:jpeg/png/webp、≤10MB),且目前相簿圖片數 < 3
|
||||
- **THEN** 系統壓縮後儲存至 `offers/{offer_id}/gallery/` 目錄(`.jpg`),建立 `course_images` 紀錄,`sort_order = MAX(sort_order) + 1`,回傳新圖片資訊
|
||||
|
||||
#### Scenario: 超過 3 張上限
|
||||
- **WHEN** 課程已有 3 張相簿圖片,Provider 再次上傳
|
||||
|
||||
@@ -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 使用 Redis,queue 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 201,body 包含 `{ 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 403,body 包含 `{ status: false }`
|
||||
|
||||
#### Scenario: Provider 刪除自己的課程
|
||||
- **WHEN** 已認證的 Provider 送出 `DELETE /api/provider/offers/{id}`(該課程屬於此 Provider)
|
||||
- **THEN** 回傳 HTTP 200,body 包含 `{ 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 201,body 包含 `{ 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 200,DB 記錄已更新
|
||||
|
||||
#### 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`(記錄保留,不刪除);已是終態的 booking(completed/expired/已取消)不受影響
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Admin 使用者管理測試覆蓋
|
||||
測試套件 SHALL 驗證 Admin 查詢會員/教練列表、切換帳號啟用狀態、切換教練驗證狀態的正確行為。
|
||||
|
||||
#### Scenario: Admin 取得會員列表
|
||||
- **WHEN** Admin 送出 `GET /api/admin/members`
|
||||
- **THEN** 回傳 HTTP 200,data 陣列只包含 role=member 的使用者
|
||||
|
||||
#### Scenario: Admin 取得教練列表
|
||||
- **WHEN** Admin 送出 `GET /api/admin/providers`
|
||||
- **THEN** 回傳 HTTP 200,data 陣列只包含 role=provider 的使用者
|
||||
|
||||
#### Scenario: Admin 切換會員帳號啟用狀態
|
||||
- **WHEN** Admin 送出 `PUT /api/admin/members/{id}/toggle-active`(目標會員 is_active=true)
|
||||
- **THEN** 回傳 HTTP 200,DB 記錄 is_active 由 true 變為 false
|
||||
|
||||
#### Scenario: Admin 核准教練審核
|
||||
- **WHEN** Admin 對 `verification_status=pending` 的教練送出 `PUT /api/admin/verifications/{userId}/approve`
|
||||
- **THEN** 回傳 HTTP 200,provider_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 id;Member B 的 booking id 不在 data 中
|
||||
|
||||
#### Scenario: Provider 只能看到屬於自己課程的預約
|
||||
- **WHEN** Provider A 呼叫 `GET /api/provider/bookings`,系統中有屬於 Provider A 與 Provider B 課程各一筆預約
|
||||
- **THEN** 回傳 data 只包含 Provider A 課程的 booking id;Provider B 課程的 booking id 不在 data 中
|
||||
|
||||
#### Scenario: Admin 可查看所有預約
|
||||
- **WHEN** Admin 呼叫 `GET /api/admin/bookings`,系統中有 2 筆不同 Member 的預約
|
||||
- **THEN** 回傳 data 包含兩筆 booking id
|
||||
|
||||
#### Scenario: 未認證請求被拒絕
|
||||
- **WHEN** 未帶 token 呼叫 `GET /api/member/bookings`
|
||||
- **THEN** 回傳 HTTP 401
|
||||
@@ -1,14 +1,14 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 登入頻率限制
|
||||
後端 SHALL 對所有登入端點套用 IP-based 頻率限制,超過限制時回傳 HTTP 429。Member 與 Provider 每 IP 每分鐘最多 5 次;Admin 因影響範圍更廣,限制為每 IP 每分鐘最多 3 次。
|
||||
後端 SHALL 對所有登入端點套用 IP-based 頻率限制,超過限制時回傳 HTTP 429。Member 與 Provider 每 IP 每分鐘最多 10 次(原定 5 次;帳號鎖定機制上線後已涵蓋暴力破解防護,放寬以容納共享 IP 場景,與實作 `throttle:10,1` 一致);Admin 因影響範圍更廣,限制為每 IP 每分鐘最多 3 次。
|
||||
|
||||
#### Scenario: Member / Provider 正常登入不受影響
|
||||
- **WHEN** 同一 IP 在 1 分鐘內對 `/api/member/login` 或 `/api/provider/login` 送出 5 次以內的請求
|
||||
- **WHEN** 同一 IP 在 1 分鐘內對 `/api/member/login` 或 `/api/provider/login` 送出 10 次以內的請求
|
||||
- **THEN** 請求正常處理,回傳對應的登入結果(200 成功或 401 失敗)
|
||||
|
||||
#### Scenario: Member / Provider 超過頻率限制
|
||||
- **WHEN** 同一 IP 在 1 分鐘內送出第 6 次 member 或 provider 登入請求
|
||||
- **WHEN** 同一 IP 在 1 分鐘內送出第 11 次 member 或 provider 登入請求
|
||||
- **THEN** 回傳 HTTP 429,並帶有 `Retry-After` header 指示等待時間
|
||||
|
||||
#### Scenario: Admin 超過頻率限制
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 專案基礎建設
|
||||
前端 SHALL 建立於獨立 repo,使用 Vue 3 + Vite + Tailwind CSS + Vue Router 4 + Pinia + Axios,並設定 `.env` 指定後端 API base URL。
|
||||
前端 SHALL 建立於本 repo 的 `frontend/` 目錄(原規劃獨立 repo,後併入主 repo 以簡化版控與部署),使用 Vue 3 + Vite + Tailwind CSS + Vue Router 4 + Pinia + Axios,並設定 `.env` 指定後端 API base URL。
|
||||
|
||||
#### Scenario: 開發環境啟動
|
||||
- **WHEN** 開發者執行 `npm run dev`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user