Compare commits
112 Commits
e7776fa930
...
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 | |||
| 51784ecc64 | |||
| ca5b843362 | |||
| 6f446f601f | |||
| 0dabc4ebb5 | |||
| ca1b83a2e3 | |||
| 855d9e1d4a | |||
| 981f753480 | |||
| 3b90edf843 | |||
| b06d8524db | |||
| 2a0e9255ae | |||
| b2e20767d8 | |||
| d9f0fe11c6 | |||
| a94c2462b8 | |||
| cb9c9a9385 | |||
| 5b13a014be | |||
| 8d1ea49abc | |||
| 9be50d493b | |||
| 0a6bfd9df8 | |||
| 127101adb3 | |||
| 0160d72467 | |||
| cfd0455597 | |||
| 8974a22584 | |||
| 9bde1ae344 | |||
| 3ad5972a5a | |||
| b770aab42a | |||
| 8d41f473e4 | |||
| 5463fd70b1 | |||
| 54dd4bb7a6 | |||
| 71b35533f4 | |||
| eff2dd273c | |||
| 4a5559a79e | |||
| 83e0e599a0 | |||
| 6fcf57a2d2 | |||
| 5ba598c78f | |||
| 3809ac7ed0 | |||
| 6b3a0816f5 | |||
| 1ba4ca893a | |||
| 6286399a3b | |||
| f80319109c | |||
| 4cf83375f3 | |||
| 91589a6f0e | |||
| ab24f210a6 | |||
| cc010b5c83 | |||
| c10d4ebfe6 | |||
| 153aee5f94 | |||
| f996a0053b | |||
| 0814a3196f | |||
| 0db38ee30d | |||
| a3462f8493 | |||
| 05ef8b9316 | |||
| 95bafef52d |
+25
-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=
|
||||
@@ -64,3 +63,24 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
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
|
||||
@@ -12,7 +12,8 @@ jobs:
|
||||
- name: Pull latest code
|
||||
run: |
|
||||
cd /root/myproject/CFDivePlatform
|
||||
git pull
|
||||
flock /tmp/cfdive-deploy.lock git fetch origin
|
||||
git reset --hard origin/master
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: |
|
||||
@@ -32,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
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Run Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
apk add --no-cache \
|
||||
php84 php84-phar php84-mbstring php84-dom php84-fileinfo php84-gd \
|
||||
php84-pdo php84-pdo_sqlite php84-sqlite3 php84-tokenizer \
|
||||
php84-xml php84-xmlwriter php84-ctype php84-curl php84-openssl \
|
||||
php84-json php84-session php84-iconv \
|
||||
curl unzip
|
||||
ln -sf /usr/bin/php84 /usr/local/bin/php
|
||||
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet
|
||||
|
||||
- name: Copy .env
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install --prefer-dist --no-interaction --no-progress
|
||||
|
||||
- name: Generate application key
|
||||
run: php artisan key:generate
|
||||
|
||||
- name: Run tests
|
||||
run: php artisan test
|
||||
@@ -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.
|
||||
+6
-9
@@ -7,6 +7,9 @@ RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
curl \
|
||||
libpng-dev \
|
||||
libjpeg62-turbo-dev \
|
||||
libfreetype6-dev \
|
||||
libwebp-dev \
|
||||
libonig-dev \
|
||||
libxml2-dev \
|
||||
zip \
|
||||
@@ -14,14 +17,14 @@ 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/*
|
||||
|
||||
# 安裝 PHP 擴展
|
||||
# 這些擴展是 Laravel 和一般 PHP 開發所需的
|
||||
# GD 需要在 install 前先 configure,才能帶入 jpeg/webp/freetype 支援
|
||||
RUN docker-php-ext-configure gd --with-jpeg --with-webp --with-freetype
|
||||
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip
|
||||
|
||||
# 從官方 Composer 鏡像複製 Composer 執行文件
|
||||
@@ -49,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"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CFDive Platform
|
||||
|
||||
潛水課程媒合平台 — 連結潛水教練與學員,提供課程瀏覽、線上預約、評價與通知等完整服務。
|
||||
潛水課程媒合平台 — 連結潛水教練與學員,提供課程瀏覽、線上預約、即時訊息、評價與通知等完整服務。
|
||||
|
||||
---
|
||||
|
||||
@@ -8,19 +8,24 @@
|
||||
|
||||
**會員(Member)**
|
||||
- 註冊 / 登入(Email + Google OAuth)
|
||||
- Token 自動續期(401 refresh-then-retry,sessionStorage 儲存)
|
||||
- 瀏覽、搜尋、篩選潛水課程
|
||||
- 查看課程時段並送出預約
|
||||
- 與教練即時訊息(文字 + 圖片,含已讀回執)
|
||||
- 對完成的課程留下評價(支援匿名、有幫助投票)
|
||||
- 站內通知(Email + Polling)
|
||||
- 站內通知(Bell Icon 即時更新 + 瀏覽器推播)
|
||||
|
||||
**教練(Provider)**
|
||||
- 課程 CRUD(含封面 + 相簿圖片上傳)
|
||||
- 課程 CRUD(含封面 + 相簿圖片上傳,伺服器端壓縮)
|
||||
- 課程時段管理
|
||||
- 預約管理(確認 / 拒絕 / 完成 / 取消)
|
||||
- 與學員即時訊息
|
||||
- 證照上傳與教練資格送審
|
||||
|
||||
**管理員(Admin)**
|
||||
- 平台統計數據(會員數、教練數、課程數)
|
||||
- 會員與教練帳號管理(啟用 / 停用 / 審核)
|
||||
- 會員與教練帳號管理(啟用 / 停用)
|
||||
- 教練資格審核(送審 / 通過 / 駁回 / 撤銷)
|
||||
- 課程、預約、評價管理
|
||||
|
||||
---
|
||||
@@ -30,20 +35,63 @@
|
||||
| 層級 | 技術 |
|
||||
|------|------|
|
||||
| 後端 | PHP 8.x / Laravel 11 |
|
||||
| 前端 | Vue 3 |
|
||||
| 前端 | Vue 3 + Vite + Tailwind CSS |
|
||||
| 資料庫 | MySQL 8.0 |
|
||||
| 快取 | Redis(predis)|
|
||||
| 認證 | Laravel Sanctum + Google OAuth |
|
||||
| 快取 | Redis |
|
||||
| 即時通訊 | Laravel Reverb(WebSocket,`wss://ws.hank-space.com`)|
|
||||
| 認證 | 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 |
|
||||
|
||||
---
|
||||
|
||||
## 健康檢查
|
||||
|
||||
```
|
||||
GET /health
|
||||
```
|
||||
|
||||
回傳 DB、Redis、Cache 狀態,供 UptimeRobot 監控使用。全部正常回 200,任一異常回 503。
|
||||
|
||||
---
|
||||
|
||||
## 本地開發
|
||||
|
||||
**測試用帳號**
|
||||
|
||||
| 角色 | 帳號 | 密碼 | 用途 |
|
||||
|------|------|------|------|
|
||||
| 會員 | Guest@cfdive.com | guestpassword | 體驗課程瀏覽、預約、會員預約紀錄 |
|
||||
| 教練 | Guest_Coach@cfdive.com | coachpassword | 體驗課程管理、時段管理、預約管理 |
|
||||
|
||||
**教練頁入口**
|
||||
|
||||
目前首頁尚未提供明顯導引到教練後台的入口。若要體驗教練功能,請直接開啟 `/coach/login`,使用上方教練試用帳號登入後會進入 `/coach/dashboard`。
|
||||
|
||||
| 服務 | 本地 | 生產 |
|
||||
|------|------|------|
|
||||
| 前端 | http://localhost:8080 | https://app.hank-space.com |
|
||||
| API | http://localhost:8080/api | https://api.hank-space.com/api |
|
||||
| Swagger UI | http://localhost:8080/api/documentation | https://api.hank-space.com/api/documentation |
|
||||
| phpMyAdmin | http://localhost:8081 | http://\<vps-ip\>:8081 |
|
||||
| Mailpit | http://localhost:8025 | http://\<vps-ip\>:8025 |
|
||||
| Reverb WebSocket | ws://localhost:8085 | wss://ws.hank-space.com |
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Broadcasting;
|
||||
|
||||
use App\Models\Booking;
|
||||
use App\Models\User;
|
||||
|
||||
class BookingPresenceChannel
|
||||
{
|
||||
public function join(User $user, Booking $booking): array|false
|
||||
{
|
||||
if ($booking->status->value !== 'confirmed') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$booking->loadMissing('schedule');
|
||||
|
||||
$isMember = $user->role === 'member' && $booking->member_id === $user->id;
|
||||
$isProvider = $user->role === 'provider'
|
||||
&& $booking->schedule !== null
|
||||
&& $booking->schedule->provider_id === $user->id;
|
||||
|
||||
if (!$isMember && !$isProvider) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return [
|
||||
'user_id' => $user->id,
|
||||
'user_type' => $user->role,
|
||||
'name' => $user->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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] ?? []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MessageRead implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $bookingId,
|
||||
public string $readerType,
|
||||
public int $lastReadMessageId,
|
||||
) {}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'MessageRead';
|
||||
}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PresenceChannel('booking.' . $this->bookingId),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'reader_type' => $this->readerType,
|
||||
'last_read_message_id' => $this->lastReadMessageId,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\BookingMessage;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MessageSent implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(public BookingMessage $message) {}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'MessageSent';
|
||||
}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PresenceChannel('booking.' . $this->message->booking_id),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->message->id,
|
||||
'sender_id' => $this->message->sender_id,
|
||||
'sender_type' => $this->message->sender_type,
|
||||
'type' => $this->message->type,
|
||||
'content' => $this->message->content,
|
||||
'created_at' => $this->message->created_at->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class NotificationCreated implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets;
|
||||
|
||||
public function __construct(public readonly int $userId) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
// 使用 Laravel 內建的 User private channel(channels.php 已有 auth)
|
||||
return [new PrivateChannel("App.Models.User.{$this->userId}")];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'notification.created';
|
||||
}
|
||||
|
||||
// 不需要 payload,前端收到後直接呼叫 fetchUnreadCount()
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,19 @@ use App\Models\User;
|
||||
use App\Models\AdminProfile;
|
||||
use App\Models\ProviderProfile;
|
||||
use App\Models\MemberProfile;
|
||||
use App\Traits\NormalizesEmail;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
// 科定規範角色
|
||||
use NormalizesEmail;
|
||||
|
||||
private const ROLE_MEMBER = 'member';
|
||||
private const ROLE_PROVIDER = 'provider';
|
||||
private const ROLE_ADMIN = 'admin';
|
||||
@@ -131,6 +135,50 @@ class AuthController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
private function isAccountLocked(string $role, string $email): bool
|
||||
{
|
||||
$count = Cache::get("login_failures:{$role}:{$email}", 0);
|
||||
return $count >= config('auth_lockout.max_attempts');
|
||||
}
|
||||
|
||||
private function recordLoginFailure(string $role, string $email): int
|
||||
{
|
||||
$ttl = config('auth_lockout.decay_minutes') * 60;
|
||||
$countKey = "login_failures:{$role}:{$email}";
|
||||
$expiresKey = "login_expires_at:{$role}:{$email}";
|
||||
|
||||
if (!Cache::has($countKey)) {
|
||||
Cache::put($countKey, 1, $ttl);
|
||||
Cache::put($expiresKey, now()->addSeconds($ttl)->toIso8601String(), $ttl);
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Cache::increment($countKey);
|
||||
}
|
||||
|
||||
private function clearLoginFailures(string $role, string $email): void
|
||||
{
|
||||
Cache::forget("login_failures:{$role}:{$email}");
|
||||
Cache::forget("login_expires_at:{$role}:{$email}");
|
||||
}
|
||||
|
||||
private function buildLockedResponse(string $role, string $email): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$decayMinutes = config('auth_lockout.decay_minutes');
|
||||
$lockedUntil = Cache::get("login_expires_at:{$role}:{$email}");
|
||||
|
||||
if (!$lockedUntil) {
|
||||
Log::warning("auth_lockout: expires_at key missing for {$role}:{$email}");
|
||||
$lockedUntil = now()->addMinutes($decayMinutes)->toIso8601String();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => "帳號已暫時鎖定,請於 {$decayMinutes} 分鐘後再試",
|
||||
'locked_until' => $lockedUntil,
|
||||
], 423);
|
||||
}
|
||||
|
||||
/**
|
||||
* 標準化的登出方法
|
||||
*/
|
||||
@@ -268,7 +316,6 @@ class AuthController extends Controller
|
||||
*/
|
||||
public function loginMember(Request $request)
|
||||
{
|
||||
// 驗證請求數據
|
||||
$validator = Validator::make($request->all(), [
|
||||
'email' => 'required|string|email',
|
||||
'password' => 'required|string',
|
||||
@@ -282,20 +329,30 @@ class AuthController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 檢查用戶是否存在
|
||||
$user = User::where('email', $request->email)
|
||||
->where('role', 'member') // 只驗證會員
|
||||
->first();
|
||||
$email = $this->normalizeEmail($request->email);
|
||||
|
||||
// 檢查密碼
|
||||
// 1st: 帳號鎖定檢查
|
||||
if ($this->isAccountLocked(self::ROLE_MEMBER, $email)) {
|
||||
return $this->buildLockedResponse(self::ROLE_MEMBER, $email);
|
||||
}
|
||||
|
||||
// 2nd: 帳號存在確認
|
||||
$user = User::where('email', $email)->where('role', self::ROLE_MEMBER)->first();
|
||||
|
||||
// 3rd: 密碼驗證(帳號不存在與密碼錯誤回傳相同訊息)
|
||||
if (!$user || !Hash::check($request->password, $user->password)) {
|
||||
if ($user) {
|
||||
$count = $this->recordLoginFailure(self::ROLE_MEMBER, $email);
|
||||
if ($count >= config('auth_lockout.max_attempts')) {
|
||||
return $this->buildLockedResponse(self::ROLE_MEMBER, $email);
|
||||
}
|
||||
}
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => '電子郵件或密碼錯誤'
|
||||
], 401);
|
||||
}
|
||||
|
||||
// 檢查用戶是否啟用
|
||||
if (!$user->is_active) {
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
@@ -303,10 +360,8 @@ class AuthController extends Controller
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 創建 API token
|
||||
$this->clearLoginFailures(self::ROLE_MEMBER, $email);
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
|
||||
// 加載會員資料
|
||||
$user->load('memberProfile');
|
||||
|
||||
return response()->json([
|
||||
@@ -531,7 +586,6 @@ class AuthController extends Controller
|
||||
*/
|
||||
public function loginProvider(Request $request)
|
||||
{
|
||||
// 驗證請求數據
|
||||
$validator = Validator::make($request->all(), [
|
||||
'email' => 'required|string|email',
|
||||
'password' => 'required|string',
|
||||
@@ -545,20 +599,30 @@ class AuthController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 檢查用戶是否存在
|
||||
$user = User::where('email', $request->email)
|
||||
->where('role', 'provider') // 只驗證服務提供者
|
||||
->first();
|
||||
$email = $this->normalizeEmail($request->email);
|
||||
|
||||
// 檢查密碼
|
||||
// 1st: 帳號鎖定檢查
|
||||
if ($this->isAccountLocked(self::ROLE_PROVIDER, $email)) {
|
||||
return $this->buildLockedResponse(self::ROLE_PROVIDER, $email);
|
||||
}
|
||||
|
||||
// 2nd: 帳號存在確認
|
||||
$user = User::where('email', $email)->where('role', self::ROLE_PROVIDER)->first();
|
||||
|
||||
// 3rd: 密碼驗證(帳號不存在與密碼錯誤回傳相同訊息)
|
||||
if (!$user || !Hash::check($request->password, $user->password)) {
|
||||
if ($user) {
|
||||
$count = $this->recordLoginFailure(self::ROLE_PROVIDER, $email);
|
||||
if ($count >= config('auth_lockout.max_attempts')) {
|
||||
return $this->buildLockedResponse(self::ROLE_PROVIDER, $email);
|
||||
}
|
||||
}
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => '電子郵件或密碼錯誤'
|
||||
], 401);
|
||||
}
|
||||
|
||||
// 檢查用戶是否啟用
|
||||
if (!$user->is_active) {
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
@@ -566,10 +630,8 @@ class AuthController extends Controller
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 創建 API token
|
||||
$this->clearLoginFailures(self::ROLE_PROVIDER, $email);
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
|
||||
// 加載服務提供者資料
|
||||
$user->load('providerProfile');
|
||||
|
||||
return response()->json([
|
||||
@@ -784,59 +846,6 @@ class AuthController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理員註冊
|
||||
*/
|
||||
public function registerAdmin(Request $request)
|
||||
{
|
||||
// 驗證請求數據
|
||||
$validator = Validator::make($request->all(), [
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|string|email|max:255|unique:users',
|
||||
'password' => 'required|string|min:6|confirmed',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'position' => 'nullable|string|max:100',
|
||||
'department' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => '驗證失敗',
|
||||
'errors' => $validator->errors()
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 創建用戶
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
'phone' => $request->phone,
|
||||
'role' => 'admin', // 強制為管理員角色
|
||||
]);
|
||||
|
||||
// 創建管理員資料
|
||||
AdminProfile::create([
|
||||
'user_id' => $user->id,
|
||||
'position' => $request->position,
|
||||
'department' => $request->department,
|
||||
]);
|
||||
|
||||
// 創建 API token
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'message' => '管理員註冊成功',
|
||||
'data' => [
|
||||
'user' => $user,
|
||||
'token' => $token,
|
||||
'token_type' => 'Bearer',
|
||||
]
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理員登入
|
||||
*/
|
||||
@@ -1051,6 +1060,63 @@ class AuthController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 會員 Token Refresh
|
||||
*/
|
||||
public function refreshMember(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if ($user->role !== self::ROLE_MEMBER) {
|
||||
return $this->getUnauthorizedResponse();
|
||||
}
|
||||
|
||||
$request->user()->currentAccessToken()->delete();
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => ['token' => $token, 'token_type' => 'Bearer'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 服務提供者 Token Refresh
|
||||
*/
|
||||
public function refreshProvider(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if ($user->role !== self::ROLE_PROVIDER) {
|
||||
return $this->getUnauthorizedResponse();
|
||||
}
|
||||
|
||||
$request->user()->currentAccessToken()->delete();
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => ['token' => $token, 'token_type' => 'Bearer'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理員 Token Refresh
|
||||
*/
|
||||
public function refreshAdmin(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if ($user->role !== self::ROLE_ADMIN) {
|
||||
return $this->getUnauthorizedResponse();
|
||||
}
|
||||
|
||||
$request->user()->currentAccessToken()->delete();
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => ['token' => $token, 'token_type' => 'Bearer'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查詢會員資料
|
||||
* 只有管理員可以使用這個方法
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Events\MessageRead;
|
||||
use App\Events\MessageSent;
|
||||
use App\Events\NotificationCreated;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Booking;
|
||||
use App\Models\BookingMessage;
|
||||
use App\Models\User;
|
||||
use App\Notifications\NewBookingMessageNotification;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Intervention\Image\ImageManager;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
|
||||
class BookingMessageController extends Controller
|
||||
{
|
||||
private function authorizeParticipant(Request $request, Booking $booking): bool
|
||||
{
|
||||
$user = $request->user();
|
||||
$booking->loadMissing('schedule');
|
||||
|
||||
if ($user->role === 'member') {
|
||||
return $booking->member_id === $user->id;
|
||||
}
|
||||
|
||||
if ($user->role === 'provider') {
|
||||
return $booking->schedule->provider_id === $user->id;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function unreadCounts(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$senderType = $user->role === 'member' ? 'member' : 'provider';
|
||||
$otherType = $senderType === 'member' ? 'provider' : 'member';
|
||||
|
||||
if ($senderType === 'member') {
|
||||
$bookingIds = Booking::where('member_id', $user->id)
|
||||
->whereIn('status', ['confirmed', 'completed'])
|
||||
->pluck('id');
|
||||
} else {
|
||||
$bookingIds = Booking::whereHas('schedule', fn($q) => $q->where('provider_id', $user->id))
|
||||
->whereIn('status', ['confirmed', 'completed'])
|
||||
->pluck('id');
|
||||
}
|
||||
|
||||
// 一次 query 取得所有 booking 的未讀數(只計對方發的、且 read_at 為 NULL)
|
||||
$counts = BookingMessage::whereIn('booking_id', $bookingIds)
|
||||
->where('sender_type', $otherType)
|
||||
->whereNull('read_at')
|
||||
->selectRaw('booking_id, COUNT(*) as count')
|
||||
->groupBy('booking_id')
|
||||
->pluck('count', 'booking_id');
|
||||
|
||||
return response()->json(['status' => true, 'data' => $counts]);
|
||||
}
|
||||
|
||||
public function index(Request $request, Booking $booking): JsonResponse
|
||||
{
|
||||
if (!$this->authorizeParticipant($request, $booking)) {
|
||||
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$status = $booking->status->value;
|
||||
if (!in_array($status, ['confirmed', 'completed'])) {
|
||||
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$messages = $booking->messages()->orderBy('created_at')->get();
|
||||
|
||||
return response()->json(['status' => true, 'data' => $messages]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Booking $booking): JsonResponse
|
||||
{
|
||||
if (!$this->authorizeParticipant($request, $booking)) {
|
||||
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
if ($booking->status->value !== 'confirmed') {
|
||||
return response()->json(['status' => false, 'message' => '訊息功能僅在預約確認期間開放'], 403);
|
||||
}
|
||||
|
||||
$request->validate(['type' => 'required|in:text,image']);
|
||||
|
||||
$user = $request->user();
|
||||
$senderType = $user->role === 'member' ? 'member' : 'provider';
|
||||
|
||||
if ($request->input('type') === 'text') {
|
||||
$request->validate(['content' => 'required|string|max:5000']);
|
||||
|
||||
$message = BookingMessage::create([
|
||||
'booking_id' => $booking->id,
|
||||
'sender_id' => $user->id,
|
||||
'sender_type' => $senderType,
|
||||
'type' => 'text',
|
||||
'content' => $request->input('content'),
|
||||
]);
|
||||
} else {
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:jpg,jpeg,png,gif,webp|max:10240',
|
||||
]);
|
||||
|
||||
$manager = new ImageManager(new Driver());
|
||||
$image = $manager->read($request->file('file'));
|
||||
|
||||
if ($image->width() > 2048 || $image->height() > 2048) {
|
||||
$image->scaleDown(width: 2048, height: 2048);
|
||||
}
|
||||
|
||||
$uuid = Str::uuid();
|
||||
$filename = "booking-images/{$uuid}.jpg";
|
||||
$encoded = $image->toJpeg(quality: 85);
|
||||
|
||||
Storage::disk('public')->put($filename, $encoded);
|
||||
$url = Storage::disk('public')->url($filename);
|
||||
|
||||
$message = BookingMessage::create([
|
||||
'booking_id' => $booking->id,
|
||||
'sender_id' => $user->id,
|
||||
'sender_type' => $senderType,
|
||||
'type' => 'image',
|
||||
'content' => $url,
|
||||
]);
|
||||
}
|
||||
|
||||
broadcast(new MessageSent($message));
|
||||
|
||||
// 通知另一方(寫入 DB notification,讓 Bell 圖示更新)
|
||||
$receiverId = $senderType === 'member'
|
||||
? $booking->schedule->provider_id
|
||||
: $booking->member_id;
|
||||
|
||||
$receiver = User::find($receiverId);
|
||||
if ($receiver) {
|
||||
// 同步寫進 DB(非 queue),確保下一行 broadcast 時 unread count 已是最新
|
||||
$receiver->notify(new NewBookingMessageNotification($message, $booking, $user));
|
||||
// 通知前端:立刻更新 bell badge,不等 polling
|
||||
broadcast(new NotificationCreated($receiver->id));
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'data' => $message], 201);
|
||||
}
|
||||
|
||||
public function markRead(Request $request, Booking $booking): JsonResponse
|
||||
{
|
||||
if (!$this->authorizeParticipant($request, $booking)) {
|
||||
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$status = $booking->status->value;
|
||||
if (!in_array($status, ['confirmed', 'completed'])) {
|
||||
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$request->validate(['last_read_message_id' => 'required|integer']);
|
||||
|
||||
$user = $request->user();
|
||||
$senderType = $user->role === 'member' ? 'member' : 'provider';
|
||||
$otherType = $senderType === 'member' ? 'provider' : 'member';
|
||||
|
||||
$updated = BookingMessage::where('booking_id', $booking->id)
|
||||
->where('sender_type', $otherType)
|
||||
->where('id', '<=', $request->input('last_read_message_id'))
|
||||
->whereNull('read_at')
|
||||
->update(['read_at' => now()]);
|
||||
|
||||
if ($updated > 0 && $status === 'confirmed') {
|
||||
broadcast(new MessageRead(
|
||||
$booking->id,
|
||||
$senderType,
|
||||
$request->input('last_read_message_id'),
|
||||
));
|
||||
}
|
||||
|
||||
return response()->json(['status' => true]);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -18,11 +18,19 @@ class SocialAuthController extends Controller
|
||||
*/
|
||||
public function redirectToGoogle()
|
||||
{
|
||||
return Socialite::driver('google')
|
||||
$state = bin2hex(random_bytes(32));
|
||||
session()->put('oauth_state', $state);
|
||||
|
||||
$redirect = Socialite::driver('google')
|
||||
->scopes(['openid', 'profile', 'email'])
|
||||
->with(['access_type' => 'offline', 'prompt' => 'consent']) // 這裡要求 prompt=consent 才能每次都獲取 refresh token
|
||||
->stateless()
|
||||
->with(['access_type' => 'offline', 'prompt' => 'consent', 'state' => $state])
|
||||
->redirect();
|
||||
|
||||
// Socialite 在 redirect() 內自己寫了一個 random state 進 session('state')。
|
||||
// 用我們的 state 覆蓋,確保 user() 的內建驗證與 URL 中的 state 一致。
|
||||
session()->put('state', $state);
|
||||
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,9 +38,16 @@ class SocialAuthController extends Controller
|
||||
*/
|
||||
public function handleGoogleCallback(Request $request)
|
||||
{
|
||||
$requestState = $request->input('state');
|
||||
$sessionState = session()->pull('oauth_state');
|
||||
|
||||
if (!$requestState || !$sessionState || !hash_equals($sessionState, $requestState)) {
|
||||
return redirect(config('app.frontend_url') . '/login?error=oauth_failed');
|
||||
}
|
||||
|
||||
try {
|
||||
// 獲取 Google 用戶資訊
|
||||
$googleUser = Socialite::driver('google')->stateless()->user();
|
||||
// 獲取 Google 用戶資訊(Socialite 內建 state 驗證此時也會通過)
|
||||
$googleUser = Socialite::driver('google')->user();
|
||||
|
||||
// 查找相關的社交帳號
|
||||
$socialAccount = SocialAccount::where('provider', 'google')
|
||||
@@ -106,9 +121,9 @@ class SocialAuthController extends Controller
|
||||
// 生成 Sanctum token
|
||||
$token = $user->createToken('google-auth')->plainTextToken;
|
||||
|
||||
return redirect(env('FRONTEND_URL') . '/auth/callback?token=' . $token);
|
||||
return redirect(config('app.frontend_url') . '/auth/callback#token=' . $token);
|
||||
} catch (\Exception $e) {
|
||||
return redirect(env('FRONTEND_URL') . '/login?error=oauth_failed');
|
||||
return redirect(config('app.frontend_url') . '/login?error=oauth_failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,4 +48,9 @@ class Booking extends Model
|
||||
{
|
||||
return $this->belongsTo(User::class, 'member_id');
|
||||
}
|
||||
|
||||
public function messages()
|
||||
{
|
||||
return $this->hasMany(BookingMessage::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BookingMessage extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'booking_id',
|
||||
'sender_id',
|
||||
'sender_type',
|
||||
'type',
|
||||
'content',
|
||||
'read_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'read_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function booking()
|
||||
{
|
||||
return $this->belongsTo(Booking::class);
|
||||
}
|
||||
|
||||
public function sender()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'sender_id');
|
||||
}
|
||||
}
|
||||
@@ -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,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Booking;
|
||||
use App\Models\BookingMessage;
|
||||
use App\Models\User;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
// 不走 Queue:通知需要在 HTTP response 前同步寫進 DB,
|
||||
// 讓後續的 NotificationCreated broadcast 能立刻讓前端拉到正確的 unread count。
|
||||
class NewBookingMessageNotification extends Notification
|
||||
{
|
||||
public function __construct(
|
||||
public readonly BookingMessage $message,
|
||||
public readonly Booking $booking,
|
||||
public readonly User $sender,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
// 聊天訊息通知只寫進 DB,不寄信(太頻繁)
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
// 顯示名稱:教練加前綴,學員直接用名字
|
||||
$senderLabel = $this->sender->role === 'provider'
|
||||
? "教練 {$this->sender->name}"
|
||||
: $this->sender->name;
|
||||
|
||||
$preview = $this->message->type === 'image'
|
||||
? '傳送了一張圖片'
|
||||
: mb_strimwidth($this->message->content, 0, 50, '…');
|
||||
|
||||
// 依收件方角色決定跳轉路徑
|
||||
$actionUrl = $notifiable->role === 'provider'
|
||||
? config('app.frontend_url') . '/coach/bookings'
|
||||
: config('app.frontend_url') . '/my-bookings';
|
||||
|
||||
return [
|
||||
'type' => 'new_message',
|
||||
'title' => "{$senderLabel} 傳來新訊息",
|
||||
'body' => $preview,
|
||||
'action_url' => $actionUrl,
|
||||
'related_id' => $this->booking->id,
|
||||
'related_type' => 'booking',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
trait NormalizesEmail
|
||||
{
|
||||
private function normalizeEmail(string $email): string
|
||||
{
|
||||
return strtolower(trim($email));
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -9,13 +9,15 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
channels: __DIR__.'/../routes/channels.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
$middleware->trustProxies(at: ['*']);
|
||||
$middleware->alias([
|
||||
'admin' => \App\Http\Middleware\EnsureAdmin::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
//
|
||||
\Sentry\Laravel\Integration::handles($exceptions);
|
||||
})->create();
|
||||
|
||||
@@ -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
|
||||
+4
-1
@@ -7,11 +7,14 @@
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"darkaonline/l5-swagger": "^9.0",
|
||||
"intervention/image": "^3.11",
|
||||
"laravel/framework": "^11.0",
|
||||
"laravel/reverb": "^1.10",
|
||||
"laravel/sanctum": "^4.1",
|
||||
"laravel/socialite": "^5.20",
|
||||
"laravel/tinker": "^2.9",
|
||||
"predis/predis": "^3.4"
|
||||
"predis/predis": "^3.4",
|
||||
"sentry/sentry-laravel": "^4.25"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
Generated
+1534
-2
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'max_attempts' => env('LOCKOUT_MAX_ATTEMPTS', 5),
|
||||
'decay_minutes' => env('LOCKOUT_DECAY_MINUTES', 15),
|
||||
];
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Broadcaster
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default broadcaster that will be used by the
|
||||
| framework when an event needs to be broadcast. You may set this to
|
||||
| any of the connections defined in the "connections" array below.
|
||||
|
|
||||
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('BROADCAST_CONNECTION', 'null'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Broadcast Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the broadcast connections that will be used
|
||||
| to broadcast events to other systems or over WebSockets. Samples of
|
||||
| each available type of connection are provided inside this array.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'reverb' => [
|
||||
'driver' => 'reverb',
|
||||
'key' => env('REVERB_APP_KEY'),
|
||||
'secret' => env('REVERB_APP_SECRET'),
|
||||
'app_id' => env('REVERB_APP_ID'),
|
||||
'options' => [
|
||||
'host' => env('REVERB_HOST'),
|
||||
'port' => env('REVERB_PORT', 443),
|
||||
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'client_options' => [
|
||||
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||
],
|
||||
],
|
||||
|
||||
'pusher' => [
|
||||
'driver' => 'pusher',
|
||||
'key' => env('PUSHER_APP_KEY'),
|
||||
'secret' => env('PUSHER_APP_SECRET'),
|
||||
'app_id' => env('PUSHER_APP_ID'),
|
||||
'options' => [
|
||||
'cluster' => env('PUSHER_APP_CLUSTER'),
|
||||
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
|
||||
'port' => env('PUSHER_PORT', 443),
|
||||
'scheme' => env('PUSHER_SCHEME', 'https'),
|
||||
'encrypted' => true,
|
||||
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'client_options' => [
|
||||
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||
],
|
||||
],
|
||||
|
||||
'ably' => [
|
||||
'driver' => 'ably',
|
||||
'key' => env('ABLY_KEY'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'driver' => 'log',
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'null',
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -5,7 +5,7 @@ return [
|
||||
'documentations' => [
|
||||
'default' => [
|
||||
'api' => [
|
||||
'title' => 'L5 Swagger UI',
|
||||
'title' => 'CFDive Platform API',
|
||||
],
|
||||
|
||||
'routes' => [
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Reverb Server
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default server used by Reverb to handle
|
||||
| incoming messages as well as broadcasting message to all your
|
||||
| connected clients. At this time only "reverb" is supported.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('REVERB_SERVER', 'reverb'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Reverb Servers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define details for each of the supported Reverb servers.
|
||||
| Each server has its own configuration options that are defined in
|
||||
| the array below. You should ensure all the options are present.
|
||||
|
|
||||
*/
|
||||
|
||||
'servers' => [
|
||||
|
||||
'reverb' => [
|
||||
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
|
||||
'port' => env('REVERB_SERVER_PORT', 8080),
|
||||
'path' => env('REVERB_SERVER_PATH', ''),
|
||||
'hostname' => env('REVERB_SERVER_HOSTNAME', null),
|
||||
'options' => [
|
||||
'tls' => [],
|
||||
],
|
||||
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
|
||||
'scaling' => [
|
||||
'enabled' => env('REVERB_SCALING_ENABLED', false),
|
||||
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
|
||||
'server' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'timeout' => env('REDIS_TIMEOUT', 60),
|
||||
],
|
||||
],
|
||||
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
|
||||
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Reverb Applications
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define how Reverb applications are managed. If you choose
|
||||
| to use the "config" provider, you may define an array of apps which
|
||||
| your server will support, including their connection credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'apps' => [
|
||||
|
||||
'provider' => 'config',
|
||||
|
||||
'apps' => [
|
||||
[
|
||||
'key' => env('REVERB_APP_KEY'),
|
||||
'secret' => env('REVERB_APP_SECRET'),
|
||||
'app_id' => env('REVERB_APP_ID'),
|
||||
'options' => [
|
||||
'host' => env('REVERB_HOST'),
|
||||
'port' => env('REVERB_PORT', 443),
|
||||
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'allowed_origins' => ['*'],
|
||||
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
|
||||
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
|
||||
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
|
||||
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
|
||||
'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'),
|
||||
'rate_limiting' => [
|
||||
'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false),
|
||||
'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60),
|
||||
'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60),
|
||||
'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false),
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
+1
-1
@@ -47,7 +47,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'expiration' => null,
|
||||
'expiration' => 60 * 24 * 7,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Sentry Laravel SDK configuration file.
|
||||
*
|
||||
* @see https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/
|
||||
*/
|
||||
return [
|
||||
|
||||
// @see https://docs.sentry.io/concepts/key-terms/dsn-explainer/
|
||||
'dsn' => env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')),
|
||||
|
||||
// @see https://spotlightjs.com/
|
||||
// 'spotlight' => env('SENTRY_SPOTLIGHT', false),
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger
|
||||
// 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')`
|
||||
|
||||
// The release version of your application
|
||||
// Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD'))
|
||||
'release' => env('SENTRY_RELEASE'),
|
||||
|
||||
// When left empty or `null` the Laravel environment will be used (usually discovered from `APP_ENV` in your `.env`)
|
||||
'environment' => env('SENTRY_ENVIRONMENT'),
|
||||
|
||||
// Override the organization ID used for trace continuation checks.
|
||||
'org_id' => env('SENTRY_ORG_ID') === null ? null : (int) env('SENTRY_ORG_ID'),
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample_rate
|
||||
'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1.0 : (float) env('SENTRY_SAMPLE_RATE'),
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces_sample_rate
|
||||
'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_TRACES_SAMPLE_RATE'),
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#profiles_sample_rate
|
||||
'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_PROFILES_SAMPLE_RATE'),
|
||||
|
||||
// Only continue incoming traces when the organization IDs are compatible with this SDK instance.
|
||||
'strict_trace_continuation' => env('SENTRY_STRICT_TRACE_CONTINUATION', false),
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_logs
|
||||
'enable_logs' => env('SENTRY_ENABLE_LOGS', false),
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#log_flush_threshold
|
||||
'log_flush_threshold' => env('SENTRY_LOG_FLUSH_THRESHOLD') === null ? null : (int) env('SENTRY_LOG_FLUSH_THRESHOLD'),
|
||||
|
||||
// The minimum log level that will be sent to Sentry as logs using the `sentry_logs` logging channel
|
||||
'logs_channel_level' => env('SENTRY_LOG_LEVEL', env('SENTRY_LOGS_LEVEL', env('LOG_LEVEL', 'debug'))),
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send_default_pii
|
||||
'send_default_pii' => env('SENTRY_SEND_DEFAULT_PII', false),
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_exceptions
|
||||
// 'ignore_exceptions' => [],
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_transactions
|
||||
'ignore_transactions' => [
|
||||
// Ignore Laravel's default health URL
|
||||
'/up',
|
||||
],
|
||||
|
||||
// Breadcrumb specific configuration
|
||||
'breadcrumbs' => [
|
||||
// Capture Laravel logs as breadcrumbs
|
||||
'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', true),
|
||||
|
||||
// Capture Laravel cache events (hits, writes etc.) as breadcrumbs
|
||||
'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', true),
|
||||
|
||||
// Capture Livewire components like routes as breadcrumbs
|
||||
'livewire' => env('SENTRY_BREADCRUMBS_LIVEWIRE_ENABLED', true),
|
||||
|
||||
// Capture SQL queries as breadcrumbs
|
||||
'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true),
|
||||
|
||||
// Capture SQL query bindings (parameters) in SQL query breadcrumbs
|
||||
'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false),
|
||||
|
||||
// Capture queue job information as breadcrumbs
|
||||
'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true),
|
||||
|
||||
// Capture command information as breadcrumbs
|
||||
'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true),
|
||||
|
||||
// Capture HTTP client request information as breadcrumbs
|
||||
'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true),
|
||||
|
||||
// Capture send notifications as breadcrumbs
|
||||
'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true),
|
||||
],
|
||||
|
||||
// Performance monitoring specific configuration
|
||||
'tracing' => [
|
||||
// Trace queue jobs as their own transactions (this enables tracing for queue jobs)
|
||||
'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', true),
|
||||
|
||||
// Capture queue jobs as spans when executed on the sync driver
|
||||
'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true),
|
||||
|
||||
// Capture SQL queries as spans
|
||||
'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true),
|
||||
|
||||
// Capture SQL query bindings (parameters) in SQL query spans
|
||||
'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false),
|
||||
|
||||
// Capture where the SQL query originated from on the SQL query spans
|
||||
'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true),
|
||||
|
||||
// Define a threshold in milliseconds for SQL queries to resolve their origin
|
||||
'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100),
|
||||
|
||||
// Capture views rendered as spans
|
||||
'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true),
|
||||
|
||||
// Capture Livewire components as spans
|
||||
'livewire' => env('SENTRY_TRACE_LIVEWIRE_ENABLED', true),
|
||||
|
||||
// Capture HTTP client requests as spans
|
||||
'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true),
|
||||
|
||||
// Capture Laravel cache events (hits, writes etc.) as spans
|
||||
'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true),
|
||||
|
||||
// Capture Redis operations as spans (this enables Redis events in Laravel)
|
||||
'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false),
|
||||
|
||||
// Capture where the Redis command originated from on the Redis command spans
|
||||
'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true),
|
||||
|
||||
// Capture send notifications as spans
|
||||
'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true),
|
||||
|
||||
// Enable tracing for requests without a matching route (404's)
|
||||
'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false),
|
||||
|
||||
// Configures if the performance trace should continue after the response has been sent to the user until the application terminates
|
||||
// This is required to capture any spans that are created after the response has been sent like queue jobs dispatched using `dispatch(...)->afterResponse()` for example
|
||||
'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true),
|
||||
|
||||
// Enable the tracing integrations supplied by Sentry (recommended)
|
||||
'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -19,7 +19,7 @@ return new class extends Migration
|
||||
$table->timestamp('email_verified_at')->nullable(); // 驗證郵件時間
|
||||
$table->string('password'); // 密碼
|
||||
$table->string('phone')->nullable(); // 電話號碼,可為空
|
||||
$table->enum('role', ['admin', 'coach', 'member'])->default('member'); // 角色:管理員、教練、會員
|
||||
$table->enum('role', ['admin', 'coach', 'member', 'provider'])->default('member'); // 角色:管理員、教練、會員、服務提供者
|
||||
$table->boolean('is_active')->default(true); // 是否啟用
|
||||
$table->rememberToken(); // 記住我 token
|
||||
$table->timestamps(); // 建立與更新時間
|
||||
|
||||
@@ -12,11 +12,17 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::getDriverName() === 'sqlite') {
|
||||
return;
|
||||
}
|
||||
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member', 'provider') NOT NULL DEFAULT 'member'");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::getDriverName() === 'sqlite') {
|
||||
return;
|
||||
}
|
||||
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member') NOT NULL DEFAULT 'member'");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?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("booking_messages", function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId("booking_id")->constrained("bookings")->cascadeOnDelete();
|
||||
$table->foreignId("sender_id")->constrained("users")->cascadeOnDelete();
|
||||
$table->enum("sender_type", ["member", "provider"]);
|
||||
$table->enum("type", ["text", "image"]);
|
||||
$table->text("content");
|
||||
$table->timestamp("read_at")->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(["booking_id", "created_at"]);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists("booking_messages");
|
||||
}
|
||||
};
|
||||
+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
|
||||
|
||||
+66
-22
@@ -38,16 +38,18 @@ 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
|
||||
container_name: cfdive-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8080:80"
|
||||
volumes:
|
||||
- ./:/var/www
|
||||
- ./docker/nginx/conf.d/:/etc/nginx/conf.d/
|
||||
@@ -58,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
|
||||
@@ -68,10 +71,16 @@ services:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_URL: ${VITE_API_URL:-https://api.hank-spack.com}
|
||||
VITE_API_URL: ${VITE_API_URL:-https://api.hank-space.com}
|
||||
VITE_REVERB_APP_KEY: ${VITE_REVERB_APP_KEY}
|
||||
VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost}
|
||||
VITE_REVERB_PORT: ${VITE_REVERB_PORT:-8085}
|
||||
VITE_REVERB_SCHEME: ${VITE_REVERB_SCHEME:-http}
|
||||
image: cfdive-frontend
|
||||
container_name: cfdive-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:5173:80"
|
||||
networks:
|
||||
- cfdive-network
|
||||
- proxy_net
|
||||
@@ -100,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:
|
||||
@@ -137,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
|
||||
@@ -150,12 +159,45 @@ services:
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- cfdive-network
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
reverb:
|
||||
image: cfdive-platform
|
||||
container_name: cfdive-reverb
|
||||
restart: unless-stopped
|
||||
working_dir: /var/www/
|
||||
entrypoint: ["php", "artisan", "reverb:start", "--host=0.0.0.0", "--port=8080"]
|
||||
ports:
|
||||
- "127.0.0.1:8085:8080"
|
||||
volumes:
|
||||
- ./:/var/www
|
||||
environment:
|
||||
- APP_KEY=${APP_KEY}
|
||||
- REVERB_APP_ID=${REVERB_APP_ID}
|
||||
- REVERB_APP_KEY=${REVERB_APP_KEY}
|
||||
- REVERB_APP_SECRET=${REVERB_APP_SECRET}
|
||||
- REVERB_HOST=reverb
|
||||
- REVERB_PORT=8080
|
||||
- REDIS_HOST=redis
|
||||
- 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
|
||||
- proxy_net
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
|
||||
networks:
|
||||
cfdive-network:
|
||||
driver: bridge
|
||||
@@ -165,3 +207,5 @@ networks:
|
||||
volumes:
|
||||
mysql-data:
|
||||
driver: local
|
||||
redis-data:
|
||||
driver: local
|
||||
|
||||
@@ -3,120 +3,61 @@ set -e
|
||||
|
||||
echo "=== CFDivePlatform 容器初始化開始 ==="
|
||||
|
||||
# 檢查目錄結構
|
||||
if [ ! -d "/var/www/storage" ]; then
|
||||
echo "創建 storage 目錄..."
|
||||
mkdir -p /var/www/storage
|
||||
fi
|
||||
|
||||
if [ ! -d "/var/www/bootstrap/cache" ]; then
|
||||
echo "創建 bootstrap/cache 目錄..."
|
||||
mkdir -p /var/www/bootstrap/cache
|
||||
fi
|
||||
|
||||
# 設置權限
|
||||
echo "設置目錄權限..."
|
||||
# 確保目錄與權限(php-fpm 啟動前必須完成)
|
||||
[ ! -d "/var/www/storage" ] && mkdir -p /var/www/storage
|
||||
[ ! -d "/var/www/bootstrap/cache" ] && mkdir -p /var/www/bootstrap/cache
|
||||
chown -R www-data:www-data /var/www
|
||||
chmod -R 775 /var/www/storage /var/www/bootstrap/cache
|
||||
|
||||
# 等待 MySQL 服務啟動
|
||||
echo "等待 MySQL 服務啟動..."
|
||||
# 確保 .env 存在
|
||||
if [ ! -f .env ]; then
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
fi
|
||||
|
||||
# 使用更穩定的方法檢查 MySQL 連接
|
||||
MAX_TRIES=60
|
||||
# 強制 DB_HOST=db(Docker service name,不能用 localhost)
|
||||
php -r "
|
||||
\$env = file_get_contents('/var/www/.env');
|
||||
\$env = preg_replace('/^DB_HOST=.*$/m', 'DB_HOST=db', \$env);
|
||||
file_put_contents('/var/www/.env', \$env);
|
||||
"
|
||||
|
||||
# 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
|
||||
# php-fpm 不等這些完成就先啟動,避免重啟時 CORS 502
|
||||
(
|
||||
echo "⏳ [背景] 等待 MySQL..."
|
||||
COUNT=0
|
||||
|
||||
wait_for_mysql() {
|
||||
while [ $COUNT -lt $MAX_TRIES ]; do
|
||||
if mysqladmin ping -h"db" -u"cfdiveuser" -p"**REMOVED**" --silent 2>/dev/null; then
|
||||
echo "✅ MySQL 服務已準備就緒"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 備用檢查方法
|
||||
if php -r "
|
||||
try {
|
||||
\$pdo = new PDO('mysql:host=db;port=3306', 'cfdiveuser', '**REMOVED**');
|
||||
echo 'PHP-PDO-OK';
|
||||
exit(0);
|
||||
} catch(Exception \$e) {
|
||||
exit(1);
|
||||
}
|
||||
" 2>/dev/null; then
|
||||
echo "✅ MySQL 連接成功 (通過 PHP PDO)"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "⏳ 等待 MySQL... ($((COUNT+1))/$MAX_TRIES)"
|
||||
until mysqladmin ping -h"db" -u"${DB_USERNAME:-cfdiveuser}" -p"${DB_PASSWORD}" --silent 2>/dev/null || [ $COUNT -ge 30 ]; do
|
||||
sleep 2
|
||||
COUNT=$((COUNT+1))
|
||||
done
|
||||
|
||||
if [ $COUNT -eq $MAX_TRIES ]; then
|
||||
echo "⚠️ 無法連接到 MySQL,但將繼續啟動服務"
|
||||
fi
|
||||
}
|
||||
echo "🗄️ [背景] 執行 migration..."
|
||||
php artisan migrate --force || echo "⚠️ migration 失敗"
|
||||
|
||||
wait_for_mysql
|
||||
|
||||
# 檢查並安裝 Composer 依賴
|
||||
echo "📦 檢查 Composer 依賴..."
|
||||
if [ -f "composer.json" ]; then
|
||||
if [ ! -d "vendor" ] || [ "composer.json" -nt "vendor/autoload.php" ]; then
|
||||
echo "安裝 Composer 依賴..."
|
||||
composer install --no-scripts --no-autoloader --optimize-autoloader
|
||||
composer dump-autoload --optimize
|
||||
else
|
||||
echo "✅ Composer 依賴已是最新"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 設置 Laravel 環境
|
||||
if [ ! -f .env ]; then
|
||||
echo "🔧 創建 .env 檔案..."
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
else
|
||||
echo "✅ .env 檔案已存在"
|
||||
fi
|
||||
|
||||
# 更新環境變數以確保正確配置
|
||||
echo "🔧 更新資料庫配置..."
|
||||
sed -i "s/DB_HOST=.*/DB_HOST=db/g" .env
|
||||
sed -i "s/DB_PASSWORD=.*/DB_PASSWORD=**REMOVED**/g" .env
|
||||
sed -i "s/DB_USERNAME=.*/DB_USERNAME=cfdiveuser/g" .env
|
||||
sed -i "s/DB_DATABASE=.*/DB_DATABASE=CFDivePlatform/g" .env
|
||||
|
||||
# 執行遷移(如果數據庫已準備好)
|
||||
echo "🗄️ 執行數據庫遷移..."
|
||||
if php artisan migrate:status 2>/dev/null; then
|
||||
php artisan migrate --force || echo "⚠️ 遷移執行遇到問題,但繼續執行"
|
||||
else
|
||||
echo "⚠️ 無法檢查遷移狀態,跳過遷移"
|
||||
fi
|
||||
|
||||
# 清除與優化 Laravel 緩存
|
||||
echo "🧹 清除 Laravel 緩存..."
|
||||
echo "🧹 [背景] 清除 Laravel 緩存..."
|
||||
php artisan config:clear || true
|
||||
php artisan cache:clear || true
|
||||
php artisan route:clear || true
|
||||
php artisan view:clear || true
|
||||
|
||||
# 生成 Swagger 文檔(如果可能)
|
||||
if php -r "echo class_exists('L5Swagger\\L5SwaggerServiceProvider') ? 'yes' : 'no';" 2>/dev/null | grep -q 'yes'; then
|
||||
echo "📖 生成 API 文檔..."
|
||||
php artisan l5-swagger:generate || echo "⚠️ API 文檔生成失敗"
|
||||
fi
|
||||
|
||||
echo "✅ CFDivePlatform 初始化完成!"
|
||||
|
||||
# 建立 storage symlink
|
||||
echo "🔗 建立 storage symlink..."
|
||||
echo "🔗 [背景] storage:link..."
|
||||
php artisan storage:link --force || true
|
||||
|
||||
# 啟動 cron daemon(Laravel Scheduler)
|
||||
echo "⏰ 啟動 Laravel Scheduler cron..."
|
||||
service cron start || cron || true
|
||||
if php -r "echo class_exists('L5Swagger\\L5SwaggerServiceProvider') ? 'yes' : 'no';" 2>/dev/null | grep -q 'yes'; then
|
||||
php artisan l5-swagger:generate || true
|
||||
fi
|
||||
|
||||
# 執行傳入的命令
|
||||
echo "✅ [背景] 初始化完成"
|
||||
) &
|
||||
|
||||
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/`。
|
||||
@@ -8,7 +8,16 @@ RUN npm install
|
||||
COPY . .
|
||||
|
||||
ARG VITE_API_URL=http://localhost:8080
|
||||
ARG VITE_REVERB_APP_KEY
|
||||
ARG VITE_REVERB_HOST=localhost
|
||||
ARG VITE_REVERB_PORT=8085
|
||||
ARG VITE_REVERB_SCHEME=http
|
||||
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
ENV VITE_REVERB_APP_KEY=$VITE_REVERB_APP_KEY
|
||||
ENV VITE_REVERB_HOST=$VITE_REVERB_HOST
|
||||
ENV VITE_REVERB_PORT=$VITE_REVERB_PORT
|
||||
ENV VITE_REVERB_SCHEME=$VITE_REVERB_SCHEME
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
Generated
+230
@@ -9,7 +9,9 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.16.0",
|
||||
"laravel-echo": "^2.3.4",
|
||||
"pinia": "^3.0.4",
|
||||
"pusher-js": "^8.5.0",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
@@ -450,6 +452,12 @@
|
||||
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
@@ -890,6 +898,23 @@
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -938,6 +963,28 @@
|
||||
"integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.5",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.5.tgz",
|
||||
"integrity": "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.20.1",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
@@ -1315,6 +1362,18 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/laravel-echo": {
|
||||
"version": "2.3.4",
|
||||
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.3.4.tgz",
|
||||
"integrity": "sha512-rpALCIK1uw2SrttcK9P5JzItt5I85RcfXQKUNnkcorzhtKeXi5GS0PVFFBH8ppNo8wnbdBKuD1EtIHgTbXo9FQ==",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pusher-js": "*",
|
||||
"socket.io-client": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -1656,6 +1715,12 @@
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
@@ -1946,6 +2011,14 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pusher-js": {
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.5.0.tgz",
|
||||
"integrity": "sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -2097,6 +2170,34 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2257,6 +2358,11 @@
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@@ -2408,6 +2514,36 @@
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -2664,6 +2800,12 @@
|
||||
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
|
||||
"dev": true
|
||||
},
|
||||
"@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"peer": true
|
||||
},
|
||||
"@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
@@ -2979,6 +3121,15 @@
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
||||
},
|
||||
"debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"ms": "^2.1.3"
|
||||
}
|
||||
},
|
||||
"delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -3018,6 +3169,25 @@
|
||||
"integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==",
|
||||
"dev": true
|
||||
},
|
||||
"engine.io-client": {
|
||||
"version": "6.6.5",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.5.tgz",
|
||||
"integrity": "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.20.1",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"peer": true
|
||||
},
|
||||
"entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
@@ -3263,6 +3433,12 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true
|
||||
},
|
||||
"laravel-echo": {
|
||||
"version": "2.3.4",
|
||||
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.3.4.tgz",
|
||||
"integrity": "sha512-rpALCIK1uw2SrttcK9P5JzItt5I85RcfXQKUNnkcorzhtKeXi5GS0PVFFBH8ppNo8wnbdBKuD1EtIHgTbXo9FQ==",
|
||||
"requires": {}
|
||||
},
|
||||
"lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -3427,6 +3603,12 @@
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"peer": true
|
||||
},
|
||||
"mz": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
@@ -3578,6 +3760,14 @@
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="
|
||||
},
|
||||
"pusher-js": {
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.5.0.tgz",
|
||||
"integrity": "sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==",
|
||||
"requires": {
|
||||
"tweetnacl": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -3675,6 +3865,28 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"socket.io-client": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
}
|
||||
},
|
||||
"socket.io-parser": {
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
}
|
||||
},
|
||||
"source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -3794,6 +4006,11 @@
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"tweetnacl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="
|
||||
},
|
||||
"update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@@ -3850,6 +4067,19 @@
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"ws": {
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"peer": true,
|
||||
"requires": {}
|
||||
},
|
||||
"xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"peer": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.16.0",
|
||||
"laravel-echo": "^2.3.4",
|
||||
"pinia": "^3.0.4",
|
||||
"pusher-js": "^8.5.0",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
|
||||
@@ -6,11 +6,76 @@ const api = axios.create({
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
const token = sessionStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
let isRefreshing = false
|
||||
let isRedirecting = false
|
||||
let pendingRequests = []
|
||||
|
||||
function resolvePending(token) {
|
||||
pendingRequests.forEach((cb) => cb(token))
|
||||
pendingRequests = []
|
||||
}
|
||||
|
||||
function rejectPending(error) {
|
||||
pendingRequests.forEach((cb) => cb(null, error))
|
||||
pendingRequests = []
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const config = error.config
|
||||
const status = error.response?.status
|
||||
|
||||
const isAuthEndpoint =
|
||||
config.url.includes('/login') ||
|
||||
config.url.includes('/register') ||
|
||||
config.url.includes('/refresh')
|
||||
|
||||
if (status !== 401 || isAuthEndpoint || config._retry) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingRequests.push((token, err) => {
|
||||
if (err) return reject(err)
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
resolve(api(config))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
config._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
const { data } = await api.post('/member/refresh')
|
||||
const newToken = data.data.token
|
||||
sessionStorage.setItem('token', newToken)
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`
|
||||
resolvePending(newToken)
|
||||
config.headers.Authorization = `Bearer ${newToken}`
|
||||
return api(config)
|
||||
} catch {
|
||||
rejectPending(error)
|
||||
if (!isRedirecting) {
|
||||
isRedirecting = true
|
||||
sessionStorage.removeItem('token')
|
||||
sessionStorage.removeItem('user')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
|
||||
@@ -6,11 +6,76 @@ const coachApi = axios.create({
|
||||
})
|
||||
|
||||
coachApi.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('coach_token')
|
||||
const token = sessionStorage.getItem('coach_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
let isRefreshing = false
|
||||
let isRedirecting = false
|
||||
let pendingRequests = []
|
||||
|
||||
function resolvePending(token) {
|
||||
pendingRequests.forEach((cb) => cb(token))
|
||||
pendingRequests = []
|
||||
}
|
||||
|
||||
function rejectPending(error) {
|
||||
pendingRequests.forEach((cb) => cb(null, error))
|
||||
pendingRequests = []
|
||||
}
|
||||
|
||||
coachApi.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const config = error.config
|
||||
const status = error.response?.status
|
||||
|
||||
const isAuthEndpoint =
|
||||
config.url.includes('/login') ||
|
||||
config.url.includes('/register') ||
|
||||
config.url.includes('/refresh')
|
||||
|
||||
if (status !== 401 || isAuthEndpoint || config._retry) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingRequests.push((token, err) => {
|
||||
if (err) return reject(err)
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
resolve(coachApi(config))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
config._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
const { data } = await coachApi.post('/provider/refresh')
|
||||
const newToken = data.data.token
|
||||
sessionStorage.setItem('coach_token', newToken)
|
||||
coachApi.defaults.headers.common['Authorization'] = `Bearer ${newToken}`
|
||||
resolvePending(newToken)
|
||||
config.headers.Authorization = `Bearer ${newToken}`
|
||||
return coachApi(config)
|
||||
} catch {
|
||||
rejectPending(error)
|
||||
if (!isRedirecting) {
|
||||
isRedirecting = true
|
||||
sessionStorage.removeItem('coach_token')
|
||||
sessionStorage.removeItem('coach_user')
|
||||
window.location.href = '/coach/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default coachApi
|
||||
|
||||
@@ -10,8 +10,8 @@ notificationApi.interceptors.request.use((config) => {
|
||||
// 兩者都存在時(測試情境),以當前頁面路徑決定:/coach 開頭用 coach_token,其餘用 token
|
||||
const isCoachPage = window.location.pathname.startsWith('/coach')
|
||||
const token = isCoachPage
|
||||
? (localStorage.getItem('coach_token') || localStorage.getItem('token'))
|
||||
: (localStorage.getItem('token') || localStorage.getItem('coach_token'))
|
||||
? (sessionStorage.getItem('coach_token') || sessionStorage.getItem('token'))
|
||||
: (sessionStorage.getItem('token') || sessionStorage.getItem('coach_token'))
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import api from '../api/axios'
|
||||
import coachApi from '../api/coachAxios'
|
||||
import echo from '../plugins/echo'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
|
||||
const props = defineProps({
|
||||
bookingId: { type: Number, required: true },
|
||||
bookingStatus: { type: String, required: true },
|
||||
currentUserType: { type: String, required: true }, // 'member' | 'provider'
|
||||
})
|
||||
|
||||
const emit = defineEmits(['read'])
|
||||
|
||||
const messages = ref([])
|
||||
const messageListRef = ref(null)
|
||||
const textInput = ref('')
|
||||
const isSending = ref(false)
|
||||
const otherUserOnline = ref(false)
|
||||
const channel = ref(null)
|
||||
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
const isConfirmed = computed(() => props.bookingStatus === 'confirmed')
|
||||
const isCompleted = computed(() => props.bookingStatus === 'completed')
|
||||
const canSend = computed(() => isConfirmed.value && !isSending.value && textInput.value.trim())
|
||||
const otherType = computed(() => props.currentUserType === 'member' ? 'provider' : 'member')
|
||||
|
||||
const axiosInstance = computed(() => props.currentUserType === 'provider' ? coachApi : api)
|
||||
|
||||
// 請求瀏覽器通知權限(只問一次)
|
||||
async function requestBrowserNotificationPermission() {
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
await Notification.requestPermission()
|
||||
}
|
||||
}
|
||||
|
||||
// 使用者不在頁面時才推瀏覽器通知
|
||||
function showBrowserNotification(msg) {
|
||||
if (!('Notification' in window) || Notification.permission !== 'granted') return
|
||||
if (!document.hidden) return // 使用者正在看這個 tab,不需要
|
||||
|
||||
const body = msg.type === 'image' ? '傳送了一張圖片' : msg.content
|
||||
new Notification('新訊息', {
|
||||
body,
|
||||
icon: '/favicon.ico',
|
||||
tag: `booking-chat-${props.bookingId}`, // 同一個預約只顯示一則,不疊加
|
||||
})
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (messageListRef.value) {
|
||||
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const res = await axiosInstance.value.get(`/bookings/${props.bookingId}/messages`)
|
||||
messages.value = res.data.data
|
||||
scrollToBottom()
|
||||
await markLastRead()
|
||||
// 使用者打開聊天室後已讀,立刻刷新 bell badge
|
||||
notificationStore.fetchUnreadCount()
|
||||
} catch (e) {
|
||||
// 403 means no access, silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function markLastRead() {
|
||||
if (!messages.value.length) return
|
||||
const lastId = messages.value[messages.value.length - 1].id
|
||||
try {
|
||||
await axiosInstance.value.post(`/bookings/${props.bookingId}/messages/read`, {
|
||||
last_read_message_id: lastId,
|
||||
})
|
||||
messages.value.forEach(m => {
|
||||
if (m.sender_type !== props.currentUserType && !m.read_at) {
|
||||
m.read_at = new Date().toISOString()
|
||||
}
|
||||
})
|
||||
emit('read') // 通知父層清除未讀角標
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function sendText() {
|
||||
if (!canSend.value) return
|
||||
const content = textInput.value.trim()
|
||||
textInput.value = ''
|
||||
isSending.value = true
|
||||
|
||||
const tempId = `_temp_${Date.now()}`
|
||||
messages.value.push({
|
||||
id: tempId,
|
||||
sender_id: null,
|
||||
sender_type: props.currentUserType,
|
||||
type: 'text',
|
||||
content,
|
||||
read_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
scrollToBottom()
|
||||
|
||||
try {
|
||||
await axiosInstance.value.post(`/bookings/${props.bookingId}/messages`, {
|
||||
type: 'text',
|
||||
content,
|
||||
})
|
||||
} catch (e) {
|
||||
textInput.value = content
|
||||
messages.value = messages.value.filter(m => m.id !== tempId)
|
||||
} finally {
|
||||
isSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendImage(event) {
|
||||
if (!isConfirmed.value) return
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
event.target.value = ''
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('type', 'image')
|
||||
formData.append('file', file)
|
||||
|
||||
isSending.value = true
|
||||
try {
|
||||
await axiosInstance.value.post(`/bookings/${props.bookingId}/messages`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('圖片上傳失敗', e)
|
||||
} finally {
|
||||
isSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeChannel() {
|
||||
channel.value = echo.join(`booking.${props.bookingId}`)
|
||||
.here(users => {
|
||||
otherUserOnline.value = users.some(u => u.user_type === otherType.value)
|
||||
// Reverb 不會發 member_added,主動 whisper 告知對方自己已上線
|
||||
channel.value?.whisper('presence', { user_type: props.currentUserType, online: true })
|
||||
})
|
||||
.joining(user => {
|
||||
if (user.user_type === otherType.value) otherUserOnline.value = true
|
||||
})
|
||||
.leaving(user => {
|
||||
if (user.user_type === otherType.value) otherUserOnline.value = false
|
||||
})
|
||||
.listenForWhisper('presence', (e) => {
|
||||
if (e.user_type === otherType.value) otherUserOnline.value = e.online
|
||||
})
|
||||
.listen('.MessageSent', async (e) => {
|
||||
if (e.sender_type === props.currentUserType) {
|
||||
// 自己送的訊息:替換樂觀更新的暫存訊息(避免重複)
|
||||
const tempIdx = messages.value.findIndex(
|
||||
m => typeof m.id === 'string' && m.id.startsWith('_temp_') && m.content === e.content
|
||||
)
|
||||
if (tempIdx !== -1) {
|
||||
messages.value[tempIdx] = { id: e.id, sender_id: e.sender_id, sender_type: e.sender_type, type: e.type, content: e.content, read_at: null, created_at: e.created_at }
|
||||
return
|
||||
}
|
||||
}
|
||||
messages.value.push({
|
||||
id: e.id,
|
||||
sender_id: e.sender_id,
|
||||
sender_type: e.sender_type,
|
||||
type: e.type,
|
||||
content: e.content,
|
||||
read_at: null,
|
||||
created_at: e.created_at,
|
||||
})
|
||||
scrollToBottom()
|
||||
if (e.sender_type !== props.currentUserType) {
|
||||
showBrowserNotification(e)
|
||||
notificationStore.fetchUnreadCount()
|
||||
await markLastRead()
|
||||
}
|
||||
})
|
||||
.listen('.MessageRead', (e) => {
|
||||
if (e.reader_type !== props.currentUserType) {
|
||||
messages.value.forEach(m => {
|
||||
if (m.sender_type === props.currentUserType && m.id <= e.last_read_message_id) {
|
||||
m.read_at = m.read_at || new Date().toISOString()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// updateEchoToken() 會 disconnect/connect,connection 重建後需重訂 presence channel
|
||||
// Pusher.js 的重連事件走 state_change,用 previous 判斷是否為重連而非初次連線
|
||||
let everConnected = false
|
||||
function onConnectionStateChange({ current }) {
|
||||
if (current !== 'connected') return
|
||||
if (!everConnected) { everConnected = true; return }
|
||||
// 重連:重訂 presence channel
|
||||
if (!isConfirmed.value) return
|
||||
echo.leave(`booking.${props.bookingId}`)
|
||||
channel.value = null
|
||||
subscribeChannel()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await requestBrowserNotificationPermission()
|
||||
await loadHistory()
|
||||
if (isConfirmed.value) {
|
||||
subscribeChannel()
|
||||
echo.connector.pusher.connection.bind('state_change', onConnectionStateChange)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
echo.connector.pusher.connection.unbind('state_change', onConnectionStateChange)
|
||||
if (channel.value) {
|
||||
channel.value.whisper('presence', { user_type: props.currentUserType, online: false })
|
||||
echo.leave(`booking.${props.bookingId}`)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isConfirmed || isCompleted" class="flex flex-col h-full border rounded-lg overflow-hidden">
|
||||
<!-- 頂部狀態列 -->
|
||||
<div class="flex items-center justify-between px-4 py-2 bg-gray-50 border-b text-sm">
|
||||
<span class="font-medium text-gray-700">訊息</span>
|
||||
<div v-if="isConfirmed" class="flex items-center gap-1.5">
|
||||
<span
|
||||
:class="otherUserOnline ? 'bg-green-400' : 'bg-gray-300'"
|
||||
class="w-2 h-2 rounded-full"
|
||||
/>
|
||||
<span class="text-gray-500">{{ otherUserOnline ? '對方在線' : '對方離線' }}</span>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">對話已封存</span>
|
||||
</div>
|
||||
|
||||
<!-- 訊息列表 -->
|
||||
<div ref="messageListRef" class="flex-1 overflow-y-auto p-4 space-y-3 bg-white" style="max-height: 400px">
|
||||
<div v-if="messages.length === 0" class="text-center text-gray-400 text-sm py-8">
|
||||
尚無訊息
|
||||
</div>
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
:class="msg.sender_type === currentUserType ? 'items-end' : 'items-start'"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<div
|
||||
:class="msg.sender_type === currentUserType
|
||||
? 'bg-blue-500 text-white rounded-br-none'
|
||||
: 'bg-gray-100 text-gray-800 rounded-bl-none'"
|
||||
class="max-w-[75%] px-3 py-2 rounded-2xl text-sm"
|
||||
>
|
||||
<img
|
||||
v-if="msg.type === 'image'"
|
||||
:src="msg.content"
|
||||
alt="圖片訊息"
|
||||
class="max-w-full rounded-lg"
|
||||
style="max-height: 200px; object-fit: contain"
|
||||
/>
|
||||
<span v-else>{{ msg.content }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 mt-0.5 text-[10px] text-gray-400">
|
||||
<span>{{ new Date(msg.created_at).toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit' }) }}</span>
|
||||
<span v-if="msg.sender_type === currentUserType">
|
||||
{{ msg.read_at ? '已讀' : '未讀' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 輸入區(僅 confirmed) -->
|
||||
<div v-if="isConfirmed" class="border-t bg-white p-3">
|
||||
<div class="flex items-end gap-2">
|
||||
<label class="flex-shrink-0 cursor-pointer text-gray-400 hover:text-blue-500 transition">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<input type="file" accept="image/*" class="hidden" @change="sendImage" :disabled="isSending" />
|
||||
</label>
|
||||
<textarea
|
||||
v-model="textInput"
|
||||
rows="1"
|
||||
placeholder="輸入訊息..."
|
||||
class="flex-1 resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:border-blue-400"
|
||||
style="max-height: 80px"
|
||||
@keydown.enter.exact.prevent="sendText"
|
||||
/>
|
||||
<button
|
||||
@click="sendText"
|
||||
:disabled="!canSend"
|
||||
class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full bg-blue-500 text-white disabled:opacity-40 transition hover:bg-blue-600"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 封存提示(completed) -->
|
||||
<div v-if="isCompleted" class="border-t bg-gray-50 px-4 py-3 text-center text-sm text-gray-400">
|
||||
課程已結束,對話已封存
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
/**
|
||||
* 追蹤多個 booking 的未讀訊息數。
|
||||
* @param {import('axios').AxiosInstance} axiosInstance - member 或 provider 的 axios
|
||||
*/
|
||||
export function useBookingUnreadCounts(axiosInstance) {
|
||||
const counts = ref({}) // { [bookingId]: number }
|
||||
let timer = null
|
||||
|
||||
async function fetchCounts() {
|
||||
try {
|
||||
const res = await axiosInstance.get('/bookings/messages/unread-counts')
|
||||
counts.value = res.data.data ?? {}
|
||||
} catch {
|
||||
// 靜默失敗,不影響主要頁面
|
||||
}
|
||||
}
|
||||
|
||||
/** 開啟聊天室後呼叫,清除該 booking 的角標 */
|
||||
function clearCount(bookingId) {
|
||||
counts.value = { ...counts.value, [bookingId]: 0 }
|
||||
}
|
||||
|
||||
/** 頁面 mount 時呼叫,立即拉取一次並啟動 60s 輪詢 */
|
||||
function startPolling() {
|
||||
fetchCounts()
|
||||
timer = setInterval(fetchCounts, 60_000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (timer) clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
onUnmounted(stopPolling)
|
||||
|
||||
return { counts, fetchCounts, clearCount, startPolling, stopPolling }
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import router from './router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { useCoachAuthStore } from './stores/coachAuth'
|
||||
import { useAdminAuthStore } from './stores/adminAuth'
|
||||
import echo from './plugins/echo'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
@@ -19,4 +20,5 @@ useCoachAuthStore().init()
|
||||
useAdminAuthStore().init()
|
||||
|
||||
app.use(router)
|
||||
app.config.globalProperties.$echo = echo
|
||||
app.mount('#app')
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import Echo from 'laravel-echo'
|
||||
import Pusher from 'pusher-js'
|
||||
|
||||
window.Pusher = Pusher
|
||||
|
||||
function getAuthToken() {
|
||||
return sessionStorage.getItem('coach_token') || sessionStorage.getItem('token') || null
|
||||
}
|
||||
|
||||
const echo = new Echo({
|
||||
broadcaster: 'reverb',
|
||||
key: import.meta.env.VITE_REVERB_APP_KEY,
|
||||
wsHost: import.meta.env.VITE_REVERB_HOST,
|
||||
wsPort: import.meta.env.VITE_REVERB_PORT ?? 443,
|
||||
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
|
||||
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
authEndpoint: `${import.meta.env.VITE_API_URL}/api/broadcasting/auth`,
|
||||
auth: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${getAuthToken()}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 登入後更新 auth header 並強制重連,確保 Reverb 用新 token 授權 channel
|
||||
export function updateEchoToken() {
|
||||
const token = getAuthToken()
|
||||
const bearer = `Bearer ${token}`
|
||||
echo.options.auth.headers.Authorization = bearer
|
||||
if (echo.connector?.pusher?.config?.auth?.headers) {
|
||||
echo.connector.pusher.config.auth.headers.Authorization = bearer
|
||||
}
|
||||
echo.disconnect()
|
||||
echo.connect()
|
||||
}
|
||||
|
||||
export default echo
|
||||
@@ -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') },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '../api/axios'
|
||||
import { useNotificationStore } from './notifications'
|
||||
import { updateEchoToken } from '../plugins/echo'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref(null)
|
||||
@@ -10,32 +11,39 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
function init() {
|
||||
const saved = localStorage.getItem('token')
|
||||
const savedUser = localStorage.getItem('user')
|
||||
const saved = sessionStorage.getItem('token')
|
||||
const savedUser = sessionStorage.getItem('user')
|
||||
if (saved) {
|
||||
token.value = saved
|
||||
user.value = savedUser ? JSON.parse(savedUser) : null
|
||||
useNotificationStore().startPolling()
|
||||
const ns = useNotificationStore()
|
||||
ns.startPolling()
|
||||
ns.startRealtime(user.value?.id)
|
||||
}
|
||||
}
|
||||
|
||||
function setAuth(userData, tokenValue) {
|
||||
user.value = userData
|
||||
token.value = tokenValue
|
||||
localStorage.setItem('token', tokenValue)
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
useNotificationStore().startPolling()
|
||||
sessionStorage.setItem('token', tokenValue)
|
||||
sessionStorage.setItem('user', JSON.stringify(userData))
|
||||
const ns = useNotificationStore()
|
||||
ns.startPolling()
|
||||
updateEchoToken()
|
||||
ns.startRealtime(userData.id)
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await api.post('/member/logout')
|
||||
} catch {}
|
||||
useNotificationStore().stopPolling()
|
||||
const ns = useNotificationStore()
|
||||
ns.stopRealtime()
|
||||
ns.stopPolling()
|
||||
user.value = null
|
||||
token.value = null
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
sessionStorage.removeItem('token')
|
||||
sessionStorage.removeItem('user')
|
||||
}
|
||||
|
||||
return { user, token, isLoggedIn, init, setAuth, logout }
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import coachApi from '../api/coachAxios'
|
||||
import { useNotificationStore } from './notifications'
|
||||
import { updateEchoToken } from '../plugins/echo'
|
||||
|
||||
export const useCoachAuthStore = defineStore('coachAuth', () => {
|
||||
const user = ref(null)
|
||||
@@ -10,32 +11,39 @@ export const useCoachAuthStore = defineStore('coachAuth', () => {
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
function init() {
|
||||
const savedToken = localStorage.getItem('coach_token')
|
||||
const savedUser = localStorage.getItem('coach_user')
|
||||
const savedToken = sessionStorage.getItem('coach_token')
|
||||
const savedUser = sessionStorage.getItem('coach_user')
|
||||
if (savedToken) {
|
||||
token.value = savedToken
|
||||
user.value = savedUser ? JSON.parse(savedUser) : null
|
||||
useNotificationStore().startPolling()
|
||||
const ns = useNotificationStore()
|
||||
ns.startPolling()
|
||||
ns.startRealtime(user.value?.id)
|
||||
}
|
||||
}
|
||||
|
||||
function setAuth(userData, tokenValue) {
|
||||
user.value = userData
|
||||
token.value = tokenValue
|
||||
localStorage.setItem('coach_token', tokenValue)
|
||||
localStorage.setItem('coach_user', JSON.stringify(userData))
|
||||
useNotificationStore().startPolling()
|
||||
sessionStorage.setItem('coach_token', tokenValue)
|
||||
sessionStorage.setItem('coach_user', JSON.stringify(userData))
|
||||
const ns = useNotificationStore()
|
||||
ns.startPolling()
|
||||
updateEchoToken()
|
||||
ns.startRealtime(userData.id)
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await coachApi.post('/provider/logout')
|
||||
} catch {}
|
||||
useNotificationStore().stopPolling()
|
||||
const ns = useNotificationStore()
|
||||
ns.stopRealtime()
|
||||
ns.stopPolling()
|
||||
user.value = null
|
||||
token.value = null
|
||||
localStorage.removeItem('coach_token')
|
||||
localStorage.removeItem('coach_user')
|
||||
sessionStorage.removeItem('coach_token')
|
||||
sessionStorage.removeItem('coach_user')
|
||||
}
|
||||
|
||||
return { user, token, isLoggedIn, init, setAuth, logout }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '../api/notificationAxios'
|
||||
import echo from '../plugins/echo'
|
||||
|
||||
export const useNotificationStore = defineStore('notifications', () => {
|
||||
const unreadCount = ref(0)
|
||||
@@ -10,6 +11,7 @@ export const useNotificationStore = defineStore('notifications', () => {
|
||||
let intervalId = null
|
||||
let currentInterval = null
|
||||
let visibilityHandler = null
|
||||
let privateChannel = null // 訂閱 private user channel 用
|
||||
|
||||
async function fetchUnreadCount() {
|
||||
try {
|
||||
@@ -23,6 +25,7 @@ export const useNotificationStore = defineStore('notifications', () => {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e?.response?.status === 401) { stopPolling(); return }
|
||||
console.error('[NotificationStore] fetchUnreadCount failed:', e?.response?.status, e?.message)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +36,7 @@ export const useNotificationStore = defineStore('notifications', () => {
|
||||
notifications.value = res.data.data
|
||||
unreadCount.value = res.data.unread_count
|
||||
} catch (e) {
|
||||
if (e?.response?.status === 401) { stopPolling(); return }
|
||||
console.error('[NotificationStore] fetchNotifications failed:', e?.response?.status, e?.message)
|
||||
}
|
||||
}
|
||||
@@ -76,6 +80,31 @@ export const useNotificationStore = defineStore('notifications', () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 訂閱使用者的 private channel,收到 notification.created 立刻更新 bell。
|
||||
* 在 startPolling() 後呼叫,需要傳入 userId。
|
||||
*/
|
||||
let realtimeUserId = null
|
||||
|
||||
function startRealtime(userId) {
|
||||
if (!userId) return
|
||||
stopRealtime() // 防止重複訂閱
|
||||
realtimeUserId = userId
|
||||
privateChannel = echo
|
||||
.private(`App.Models.User.${userId}`)
|
||||
.listen('.notification.created', () => {
|
||||
fetchUnreadCount()
|
||||
})
|
||||
}
|
||||
|
||||
function stopRealtime() {
|
||||
if (realtimeUserId) {
|
||||
echo.leave(`App.Models.User.${realtimeUserId}`)
|
||||
realtimeUserId = null
|
||||
privateChannel = null
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead(id) {
|
||||
const n = notifications.value.find(n => n.id === id)
|
||||
if (n && !n.read_at) {
|
||||
@@ -110,6 +139,7 @@ export const useNotificationStore = defineStore('notifications', () => {
|
||||
unreadCount, notifications, isOpen,
|
||||
fetchNotifications, fetchUnreadCount,
|
||||
startPolling, stopPolling,
|
||||
startRealtime, stopRealtime,
|
||||
markRead, markAllRead, remove,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
onMounted(async () => {
|
||||
const token = route.query.token
|
||||
const token = new URLSearchParams(window.location.hash.substring(1)).get('token')
|
||||
const error = route.query.error
|
||||
|
||||
if (error || !token) {
|
||||
@@ -18,7 +18,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
// 存 token 先,再拉 profile
|
||||
localStorage.setItem('token', token)
|
||||
sessionStorage.setItem('token', token)
|
||||
try {
|
||||
const res = await api.get('/member/profile')
|
||||
auth.setAuth(res.data.data, token)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const oauthError = route.query.error === 'oauth_failed'
|
||||
? 'Google 登入失敗,請重試。'
|
||||
? 'OAuth 授權失敗,請重新嘗試'
|
||||
: ''
|
||||
|
||||
const successMsg = route.query.registered ? '註冊成功,請登入。' : ''
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getMyBookings, cancelBooking } from '../api/bookingApi'
|
||||
import BookingChat from '../components/BookingChat.vue'
|
||||
import { useBookingUnreadCounts } from '../composables/useBookingUnreadCounts'
|
||||
import api from '../api/axios'
|
||||
|
||||
const bookings = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const expanded = ref(new Set())
|
||||
|
||||
const { counts: unreadCounts, clearCount, startPolling } = useBookingUnreadCounts(api)
|
||||
|
||||
const STATUS_LABEL = {
|
||||
pending: { text: '待教練確認', color: 'bg-yellow-100 text-yellow-700', hint: '等待教練確認中,確認後才完成預約' },
|
||||
confirmed: { text: '預約成功', color: 'bg-green-100 text-green-700', hint: '教練已確認,請準時出席' },
|
||||
@@ -26,6 +31,7 @@ onMounted(async () => {
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
startPolling()
|
||||
})
|
||||
|
||||
function toggle(id) {
|
||||
@@ -84,6 +90,16 @@ function formatDate(dateStr) {
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<!-- 未讀訊息角標 -->
|
||||
<span
|
||||
v-if="(unreadCounts[b.id] ?? 0) > 0"
|
||||
class="flex items-center gap-1 bg-red-500 text-white text-[10px] font-bold px-2 py-0.5 rounded-full leading-none"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
||||
</svg>
|
||||
{{ unreadCounts[b.id] }}
|
||||
</span>
|
||||
<span class="text-xs px-3 py-1 rounded-full font-medium" :class="STATUS_LABEL[b.status]?.color">
|
||||
{{ STATUS_LABEL[b.status]?.text || b.status }}
|
||||
</span>
|
||||
@@ -141,6 +157,15 @@ function formatDate(dateStr) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 即時訊息(confirmed / completed) -->
|
||||
<BookingChat
|
||||
v-if="b.status === 'confirmed' || b.status === 'completed'"
|
||||
:bookingId="b.id"
|
||||
:bookingStatus="b.status"
|
||||
currentUserType="member"
|
||||
@read="clearCount(b.id)"
|
||||
/>
|
||||
|
||||
<!-- 操作按鈕列 -->
|
||||
<div class="flex items-center justify-between pt-1">
|
||||
<RouterLink
|
||||
|
||||
@@ -5,6 +5,21 @@ import adminApi from '../../api/adminAxios'
|
||||
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>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { getProviderBookings, confirmBooking, rejectBooking, cancelBooking, completeBooking } from '../../api/coachBookingApi'
|
||||
import BookingChat from '../../components/BookingChat.vue'
|
||||
import { useBookingUnreadCounts } from '../../composables/useBookingUnreadCounts'
|
||||
import coachApi from '../../api/coachAxios'
|
||||
|
||||
const bookings = ref([])
|
||||
const loading = ref(true)
|
||||
@@ -31,8 +34,23 @@ const groupedByOffer = computed(() => {
|
||||
})
|
||||
|
||||
const pendingCount = computed(() => bookings.value.filter(b => b.status === 'pending').length)
|
||||
const chatExpanded = ref(new Set())
|
||||
|
||||
onMounted(fetchBookings)
|
||||
const { counts: unreadCounts, clearCount, startPolling } = useBookingUnreadCounts(coachApi)
|
||||
|
||||
function toggleChat(id) {
|
||||
if (chatExpanded.value.has(id)) chatExpanded.value.delete(id)
|
||||
else chatExpanded.value.add(id)
|
||||
}
|
||||
|
||||
function canChat(status) {
|
||||
return status === 'confirmed' || status === 'completed'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchBookings()
|
||||
startPolling()
|
||||
})
|
||||
|
||||
async function fetchBookings() {
|
||||
loading.value = true
|
||||
@@ -88,9 +106,10 @@ async function doAction(booking, action) {
|
||||
<div
|
||||
v-for="b in group"
|
||||
:key="b.id"
|
||||
class="bg-white rounded-xl border px-5 py-4 flex items-start justify-between flex-wrap gap-3"
|
||||
class="bg-white rounded-xl border overflow-hidden"
|
||||
:class="b.status === 'pending' ? 'border-yellow-200 shadow-sm' : 'border-gray-100'"
|
||||
>
|
||||
<div class="px-5 py-4 flex items-start justify-between flex-wrap gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-700">
|
||||
{{ b.scheduled_date }} {{ b.start_time }}
|
||||
@@ -107,7 +126,7 @@ async function doAction(booking, action) {
|
||||
<span class="text-xs px-3 py-1 rounded-full font-medium" :class="STATUS_LABEL[b.status]?.color">
|
||||
{{ STATUS_LABEL[b.status]?.text || b.status }}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 flex-wrap justify-end">
|
||||
<button v-if="b.status === 'pending'" @click="doAction(b, 'confirm')"
|
||||
class="text-xs bg-green-600 hover:bg-green-500 text-white px-3 py-1 rounded-full transition">
|
||||
確認
|
||||
@@ -124,9 +143,33 @@ async function doAction(booking, action) {
|
||||
class="text-xs text-orange-500 hover:text-orange-700 underline">
|
||||
取消
|
||||
</button>
|
||||
<button v-if="canChat(b.status)" @click="toggleChat(b.id)"
|
||||
class="relative text-xs border px-3 py-1 rounded-full transition"
|
||||
:class="chatExpanded.has(b.id)
|
||||
? 'border-blue-400 text-blue-600'
|
||||
: 'border-gray-300 hover:border-blue-400 hover:text-blue-600 text-gray-600'"
|
||||
>
|
||||
{{ chatExpanded.has(b.id) ? '收起訊息' : '訊息' }}
|
||||
<!-- 未讀紅點 -->
|
||||
<span
|
||||
v-if="(unreadCounts[b.id] ?? 0) > 0 && !chatExpanded.has(b.id)"
|
||||
class="absolute -top-1 -right-1 min-w-[1rem] h-4 flex items-center justify-center bg-red-500 text-white text-[9px] font-bold rounded-full px-0.5"
|
||||
>{{ unreadCounts[b.id] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 即時訊息(confirmed / completed,點擊展開) -->
|
||||
<div v-if="canChat(b.status) && chatExpanded.has(b.id)" class="border-t border-gray-100 p-4">
|
||||
<BookingChat
|
||||
:bookingId="b.id"
|
||||
:bookingStatus="b.status"
|
||||
currentUserType="provider"
|
||||
@read="clearCount(b.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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-05-26
|
||||
@@ -0,0 +1,36 @@
|
||||
## Why
|
||||
|
||||
預約成立後,會員與教練之間沒有直接溝通管道,只能透過平台外的通訊工具協調集合地點、裝備確認等細節,造成使用流程斷裂。加入即時訊息功能可以將溝通閉環留在平台內,提升黏著度與信任感。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增 `booking_messages` 資料表,儲存文字與圖片訊息,與 `bookings` 一對多關聯
|
||||
- 新增站內通知系統:發送訊息時同步寫入 `notifications` 資料表(`NewBookingMessageNotification`,database channel only),並廣播 `NotificationCreated` event 至 `private-App.Models.User.{receiverId}` 頻道,前端即時更新 Bell Icon 未讀計數(**實作中追加,超出原始 scope**)
|
||||
- 新增瀏覽器 Web Notification:分頁在背景時收到對方訊息,透過 Notifications API 推送原生通知(`tag: booking-chat-{id}` 防疊加)(**實作中追加**)
|
||||
- 引入 **Laravel Reverb** 作為自架 WebSocket 伺服器(docker-compose 新增 `reverb` service)
|
||||
- 每個 confirmed 預約建立一條 **Presence Channel**(`presence-booking.{id}`),同時承載訊息推播與在線狀態
|
||||
- 已讀回執:對方在頻道內且讀取訊息時觸發 `MessageRead` event
|
||||
- 圖片訊息:透過 HTTP POST 上傳至 Laravel Storage,WebSocket 廣播包含圖片 URL
|
||||
- 訊息視窗隨預約狀態開關:`confirmed` → 可讀寫;`completed` → 封存唯讀;其餘狀態不開放
|
||||
- 新增 DNS subdomain `ws.hank-space.com`,透過 Nginx Proxy Manager 獨立 Proxy Host 接入(B 方案)
|
||||
- 前端(Vue 3)新增訊息面板,嵌入 Member 的預約詳情頁與 Coach 的預約管理頁
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `booking-chat`: 預約確認後的即時文字與圖片訊息,含訊息歷史、在線狀態、已讀回執,課程結束後封存
|
||||
- `user-presence`: 基於 Presence Channel 的每預約在線狀態追蹤(誰在線、加入/離開事件)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `booking-lifecycle`: 預約狀態機新增「訊息視窗開關」語義——`confirmed` 開啟可讀寫頻道,`completed` 切換為封存唯讀,其餘終態(rejected、expired、cancelled)無訊息頻道
|
||||
|
||||
## Impact
|
||||
|
||||
- **新增套件**:`laravel/reverb`(PHP)、`intervention/image`(PHP)、`laravel-echo`、`pusher-js`(npm)
|
||||
- **新增 Docker service**:`reverb`(連接 `cfdive-network` 與 `proxy_net`,port 8080 僅內網)
|
||||
- **新增 API 端點**:訊息列表(`GET /api/bookings/{id}/messages`)、發送文字/圖片(`POST /api/bookings/{id}/messages`)、標記已讀(`POST /api/bookings/{id}/messages/read`)、批次未讀計數(`GET /api/bookings/messages/unread-counts`)
|
||||
- **修改**:`docker-compose.yml`、`config/broadcasting.php`(connection 改為 `reverb`)
|
||||
- **Infrastructure**:NPM 新增 `ws.hank-space.com` Proxy Host(WebSocket 啟用)、DNS A Record
|
||||
- **不影響**:現有預約 API、評價系統、所有已完成模組
|
||||
@@ -0,0 +1,134 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 發送文字訊息
|
||||
Member 與 Provider SHALL 能在 `confirmed` 狀態的預約中透過 `POST /api/bookings/{id}/messages` 發送文字訊息。訊息儲存後系統 SHALL 廣播 `MessageSent` event 至 `presence-booking.{id}` 頻道。
|
||||
|
||||
#### Scenario: 成功發送文字訊息
|
||||
- **WHEN** 已認證的 Member 或 Provider 對自己參與的 `confirmed` 預約送出 `POST /api/bookings/{id}/messages`,body 為 `{ type: 'text', content: '...' }`
|
||||
- **THEN** 系統建立 `BookingMessage` 記錄,回傳 201,並透過 WebSocket 廣播 `MessageSent` event(含 `id`、`sender_id`、`sender_type`、`type`、`content`、`created_at`)
|
||||
|
||||
#### Scenario: 預約非 confirmed 狀態時不可發送
|
||||
- **WHEN** 預約 status 不為 `confirmed`
|
||||
- **THEN** 系統回傳 403,告知訊息功能僅在預約確認期間開放
|
||||
|
||||
#### Scenario: 非參與方不可發送
|
||||
- **WHEN** 非該預約的 Member 或對應 Provider 嘗試發送訊息
|
||||
- **THEN** 系統回傳 403 Forbidden
|
||||
|
||||
#### Scenario: 訊息內容不可為空
|
||||
- **WHEN** `content` 為空字串或未提供
|
||||
- **THEN** 系統回傳 422,`errors.content` 說明必填
|
||||
|
||||
### Requirement: 發送圖片訊息
|
||||
Member 與 Provider SHALL 能在 `confirmed` 預約中上傳圖片作為訊息。圖片透過 HTTP multipart POST 上傳至 Laravel Storage,系統建立 `type: image` 的 `BookingMessage` 並廣播圖片 URL。
|
||||
|
||||
#### Scenario: 成功上傳並發送圖片
|
||||
- **WHEN** 已認證使用者對 `confirmed` 預約送出 `POST /api/bookings/{id}/messages`,`Content-Type: multipart/form-data`,`type: image`,`file` 為有效圖片(jpg/png/gif/webp,≤ 10MB)
|
||||
- **THEN** 系統儲存圖片至 Storage,建立 `BookingMessage`(`content` 存圖片 URL),廣播 `MessageSent` event,回傳 201
|
||||
|
||||
#### Scenario: 不支援的檔案類型被拒絕
|
||||
- **WHEN** 上傳的 `file` 非 jpg/png/gif/webp
|
||||
- **THEN** 系統回傳 422,`errors.file` 說明僅支援圖片格式
|
||||
|
||||
#### Scenario: 超過大小限制被拒絕
|
||||
- **WHEN** 圖片檔案大小超過 10MB
|
||||
- **THEN** 系統回傳 422,`errors.file` 說明大小限制
|
||||
|
||||
### Requirement: 讀取訊息歷史
|
||||
Member 與 Provider SHALL 能透過 `GET /api/bookings/{id}/messages` 取得該預約的完整訊息歷史,按 `created_at` 升序排列。
|
||||
|
||||
#### Scenario: 成功取得訊息歷史
|
||||
- **WHEN** 已認證的參與方送出 `GET /api/bookings/{id}/messages`
|
||||
- **THEN** 系統回傳訊息陣列,每筆包含 `id`、`sender_id`、`sender_type`、`type`、`content`、`read_at`、`created_at`
|
||||
|
||||
#### Scenario: 非參與方無法讀取歷史
|
||||
- **WHEN** 非該預約參與方嘗試讀取訊息
|
||||
- **THEN** 系統回傳 403 Forbidden
|
||||
|
||||
#### Scenario: 預約完成後仍可讀取歷史
|
||||
- **WHEN** 預約 status 為 `completed`,參與方送出 `GET /api/bookings/{id}/messages`
|
||||
- **THEN** 系統回傳完整歷史訊息(唯讀,不可繼續發送)
|
||||
|
||||
### Requirement: 訊息封存
|
||||
預約狀態轉換為 `completed` 時,系統 SHALL 自動關閉對應 Presence Channel,訊息歷史轉為唯讀。終態(rejected、expired、member_cancelled、provider_cancelled)的預約 SHALL 不具備訊息功能。
|
||||
|
||||
#### Scenario: completed 後不可發送新訊息
|
||||
- **WHEN** 預約 status 為 `completed`,任一方嘗試 `POST /api/bookings/{id}/messages`
|
||||
- **THEN** 系統回傳 403,告知預約已結束,訊息已封存
|
||||
|
||||
#### Scenario: 終態預約無訊息記錄也無法發送
|
||||
- **WHEN** 預約 status 為 `rejected`、`expired`、`member_cancelled` 或 `provider_cancelled`
|
||||
- **THEN** `GET /api/bookings/{id}/messages` 回傳空陣列;`POST` 回傳 403
|
||||
|
||||
### Requirement: 標記訊息已讀
|
||||
接收方讀取訊息時,系統 SHALL 更新 `read_at`,並廣播 `MessageRead` event 至頻道。
|
||||
|
||||
#### Scenario: 成功標記已讀
|
||||
- **WHEN** 已認證使用者送出 `POST /api/bookings/{id}/messages/read`(含 `last_read_message_id`)
|
||||
- **THEN** 系統將該訊息及之前所有訊息(自己未讀的)的 `read_at` 更新為當前時間,廣播 `MessageRead` event(含 `reader_type`、`last_read_message_id`)
|
||||
|
||||
#### Scenario: 不可標記自己的訊息
|
||||
- **WHEN** 發送方嘗試標記自己發出的訊息為已讀
|
||||
- **THEN** 系統忽略(不更新、不廣播),回傳 200
|
||||
|
||||
### Requirement: 未讀訊息計數 Endpoint
|
||||
系統 SHALL 提供 `GET /api/bookings/messages/unread-counts` endpoint,一次回傳當前使用者所有相關預約的未讀訊息數,避免前端逐筆呼叫造成 N+1 請求。
|
||||
|
||||
#### Scenario: 取得未讀計數
|
||||
- **WHEN** 已認證的 Member 或 Provider 送出 `GET /api/bookings/messages/unread-counts`
|
||||
- **THEN** 系統回傳 `{ status: true, data: { "{bookingId}": count, ... } }`,僅包含未讀數 > 0 的 booking;無未讀則回傳空物件 `{}`
|
||||
- **AND** 計算邏輯為:對方發送(`sender_type` 為對方角色)且 `read_at IS NULL` 的訊息數量
|
||||
|
||||
#### Scenario: 路由優先順序正確
|
||||
- **WHEN** 路由檔案定義 `/bookings/messages/unread-counts`
|
||||
- **THEN** 該路由必須在 `/bookings/{booking}/messages` 之前註冊,以防 Route Model Binding 將 `messages` 誤判為 `{booking}` 參數
|
||||
|
||||
### Requirement: 預約列表未讀角標
|
||||
前端 SHALL 在預約列表(Member 的 `MyBookingsView`、Coach 的 `BookingManagerView`)的每筆預約卡片上,以紅色角標顯示未讀訊息數。
|
||||
|
||||
#### Scenario: 有未讀訊息時顯示角標
|
||||
- **WHEN** `GET /api/bookings/messages/unread-counts` 回傳某 booking 的 count > 0
|
||||
- **THEN** 對應預約卡片上顯示紅色角標(數字)
|
||||
|
||||
#### Scenario: 開啟聊天視窗後角標即時清零
|
||||
- **WHEN** 使用者展開某預約的 `BookingChat` 元件,元件 mount 時呼叫 `markRead`
|
||||
- **THEN** `BookingChat` emit `read` 事件,父層呼叫 `clearCount(bookingId)` 即時清零角標,無需等下一輪 60s 輪詢
|
||||
|
||||
### Requirement: 站內通知(Bell Icon)即時更新
|
||||
當使用者收到新的聊天訊息時,系統 SHALL 即時更新其 Bell Icon 的未讀通知計數,延遲不超過廣播傳輸時間(毫秒級),不依賴輪詢。
|
||||
|
||||
#### Scenario: 發送訊息觸發接收方 Bell 更新
|
||||
- **WHEN** 使用者 A 透過 `POST /api/bookings/{id}/messages` 成功發送訊息
|
||||
- **THEN** 系統同步(非 queue)寫入 `notifications` 資料表(`NewBookingMessageNotification`,database channel only)
|
||||
- **AND** 系統 broadcast `NotificationCreated` event 至接收方的 `private-App.Models.User.{receiverId}` 頻道
|
||||
- **AND** 接收方前端收到 `.notification.created` 事件後立即重新 fetch `/notifications/unread-count`
|
||||
|
||||
#### Scenario: 通知 title 包含寄件方姓名
|
||||
- **WHEN** 通知寫入 DB
|
||||
- **THEN** `title` 欄位格式為 `"{senderLabel} 傳來新訊息"`,其中 Provider 的 senderLabel 加上「教練 」前綴(例:「教練 王小明 傳來新訊息」),Member 直接使用姓名
|
||||
|
||||
#### Scenario: 不寄送 Email 通知
|
||||
- **WHEN** 新訊息通知寫入
|
||||
- **THEN** `via()` 僅回傳 `['database']`,不透過 mail channel 發送 Email
|
||||
|
||||
### Requirement: 瀏覽器 Web Notification(背景通知)
|
||||
當使用者的瀏覽器分頁處於背景(`document.hidden === true`)時,系統 SHALL 觸發瀏覽器原生通知,告知有新的聊天訊息。
|
||||
|
||||
#### Scenario: 分頁在背景時收到訊息顯示通知
|
||||
- **WHEN** `BookingChat` 收到對方的 `MessageSent` event
|
||||
- **AND** `document.hidden === true`
|
||||
- **AND** `Notification.permission === 'granted'`
|
||||
- **THEN** 系統建立一則 `Notification`,`tag: 'booking-chat-{bookingId}'`(防止同預約疊加多則)
|
||||
|
||||
#### Scenario: 分頁在前景時不顯示通知
|
||||
- **WHEN** `BookingChat` 收到對方的 `MessageSent` event
|
||||
- **AND** `document.hidden === false`(使用者正在看畫面)
|
||||
- **THEN** 系統不推送 Web Notification(避免干擾)
|
||||
|
||||
#### Scenario: 元件 mount 時請求通知權限
|
||||
- **WHEN** `BookingChat` 元件 mount
|
||||
- **THEN** 呼叫 `Notification.requestPermission()`,取得使用者授權後方可推送通知
|
||||
|
||||
#### Scenario: 自己的訊息不觸發通知
|
||||
- **WHEN** `MessageSent` event 的 `sender_type` 與當前使用者角色相同
|
||||
- **THEN** 不推送 Web Notification(只對「對方」的訊息觸發)
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 預約狀態機
|
||||
系統 SHALL 維護七個合法狀態,且只允許以下轉換:
|
||||
- `pending` → `confirmed`(Provider 確認)
|
||||
- `pending` → `rejected`(Provider 拒絕)
|
||||
- `pending` → `member_cancelled`(Member 取消)
|
||||
- `pending` → `expired`(Scheduler 超時)
|
||||
- `confirmed` → `completed`(Scheduler 課程後自動)
|
||||
- `confirmed` → `member_cancelled`(Member 取消)
|
||||
- `confirmed` → `provider_cancelled`(Provider 取消)
|
||||
|
||||
各狀態對應訊息頻道語義:
|
||||
- `confirmed`:`presence-booking.{id}` 頻道開放,可讀寫訊息
|
||||
- `completed`:頻道關閉,訊息歷史封存唯讀
|
||||
- 其餘狀態(`pending`、`rejected`、`expired`、`member_cancelled`、`provider_cancelled`):無訊息頻道
|
||||
|
||||
#### Scenario: 非法狀態轉換被拒絕
|
||||
- **WHEN** 任何角色嘗試執行上述以外的狀態轉換
|
||||
- **THEN** 系統回傳 422,說明當前狀態不允許此操作
|
||||
|
||||
#### Scenario: confirmed 轉 completed 時封存訊息頻道
|
||||
- **WHEN** Scheduler 將 `confirmed` 預約轉為 `completed`
|
||||
- **THEN** 對應 `presence-booking.{id}` 頻道不再授權新連線;現有訊息歷史保留,後續 POST 訊息回傳 403
|
||||
@@ -0,0 +1,53 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 訂閱預約 Presence Channel
|
||||
已認證的 Member 與 Provider SHALL 能透過 Laravel Echo 訂閱 `presence-booking.{booking_id}` 頻道,訂閱時系統 SHALL 驗證使用者確為該預約的參與方。
|
||||
|
||||
#### Scenario: 合法參與方成功訂閱
|
||||
- **WHEN** 已認證使用者帶有效 Bearer token 連線至 `wss://ws.hank-space.com`,並訂閱 `presence-booking.{id}`
|
||||
- **THEN** `broadcasting/auth` 端點回傳授權成功,使用者加入頻道,頻道廣播 `joining` event(含 `user_id`、`user_type`、`name`)
|
||||
|
||||
#### Scenario: 非參與方訂閱被拒絕
|
||||
- **WHEN** 非該預約參與方嘗試訂閱頻道
|
||||
- **THEN** `broadcasting/auth` 回傳 403,連線不建立
|
||||
|
||||
#### Scenario: 預約非 confirmed 狀態時頻道不授權
|
||||
- **WHEN** 預約 status 不為 `confirmed`,任何使用者嘗試訂閱
|
||||
- **THEN** `broadcasting/auth` 回傳 403
|
||||
|
||||
### Requirement: 在線狀態感知
|
||||
訂閱 Presence Channel 後,系統 SHALL 回傳目前在線成員清單。任一方加入或離開時 SHALL 廣播對應事件。
|
||||
|
||||
#### Scenario: 取得目前在線清單
|
||||
- **WHEN** 使用者成功加入 `presence-booking.{id}` 頻道
|
||||
- **THEN** Echo `.here()` callback 收到目前在線的使用者清單(含 `user_id`、`user_type`、`name`)
|
||||
|
||||
#### Scenario: 對方加入頻道
|
||||
- **WHEN** 對方(Member 或 Provider)開啟訊息視窗並成功訂閱頻道
|
||||
- **THEN** 已在頻道中的使用者收到 `.joining()` event,可顯示「對方已上線」
|
||||
|
||||
#### Scenario: 對方離開頻道
|
||||
- **WHEN** 對方關閉訊息視窗或斷線
|
||||
- **THEN** 仍在頻道中的使用者收到 `.leaving()` event,可顯示「對方已離線」
|
||||
|
||||
### Requirement: 已讀回執顯示
|
||||
前端 SHALL 依據 `MessageRead` event 更新訊息的已讀狀態,顯示「已讀」標記。
|
||||
|
||||
#### Scenario: 己方訊息被對方讀取後顯示已讀
|
||||
- **WHEN** 頻道收到 `MessageRead` event,`reader_type` 為對方,`last_read_message_id` >= 某訊息 id
|
||||
- **THEN** 前端將該訊息及之前的己方訊息顯示「已讀」標記
|
||||
|
||||
#### Scenario: 對方不在頻道時訊息顯示未讀
|
||||
- **WHEN** 對方未訂閱頻道(離線)
|
||||
- **THEN** 新發送的訊息 `read_at` 為 null,顯示「未讀」狀態
|
||||
|
||||
### Requirement: 未讀訊息計數
|
||||
系統 SHALL 在預約列表頁顯示每個預約的未讀訊息數量角標。
|
||||
|
||||
#### Scenario: 有未讀訊息時顯示角標
|
||||
- **WHEN** Member 或 Provider 進入預約列表,某預約有 `read_at IS NULL` 且 `sender_type` 為對方的訊息
|
||||
- **THEN** 對應預約卡片顯示未讀數量角標(數字或紅點)
|
||||
|
||||
#### Scenario: 進入訊息視窗後角標清除
|
||||
- **WHEN** 使用者進入該預約的訊息視窗,送出 `POST /api/bookings/{id}/messages/read`
|
||||
- **THEN** 未讀角標消失
|
||||
@@ -0,0 +1,78 @@
|
||||
## 1. 基礎建設與套件安裝
|
||||
|
||||
- [x] 1.1 [後端] 安裝 `laravel/reverb`:`composer require laravel/reverb`,執行 `php artisan reverb:install` 生成 `config/reverb.php`
|
||||
- [x] 1.2 [後端] 安裝 `intervention/image`:`composer require intervention/image`;用途:上傳圖片時移除 EXIF(含 GPS 座標)、強制重新編碼為 jpg/png 以防格式偽裝、限制最大尺寸(長邊 2048px)
|
||||
- [x] 1.3 [前端] 安裝 `laravel-echo` 與 `pusher-js`:`npm install laravel-echo pusher-js`
|
||||
- [x] 1.4 [後端] 更新 `.env`:設定 `BROADCAST_CONNECTION=reverb`、`REVERB_APP_ID=cfdive`、`REVERB_APP_KEY`(32 字元隨機)、`REVERB_APP_SECRET`(32 字元隨機)、`REVERB_HOST=reverb`(Docker service DNS,非 bind address)、`REVERB_PORT=8080`;另補 Vite 前端用的 `VITE_REVERB_APP_KEY`、`VITE_REVERB_HOST=ws.hank-space.com`、`VITE_REVERB_PORT=443`、`VITE_REVERB_SCHEME=https`
|
||||
- [x] 1.5 [後端] 在 `config/broadcasting.php` 確認 `reverb` driver 設定正確(`host`、`port` 從 env 讀取)
|
||||
- [x] 1.6 [後端] 在 `bootstrap/app.php` 啟用 broadcasting:確認 `->withBroadcasting()` 已加入,或在 `routes/api.php` 明確呼叫 `Broadcast::routes(['middleware' => ['auth:sanctum']])` 以確保 `/broadcasting/auth` endpoint 存在並受 Sanctum 保護
|
||||
|
||||
## 2. Docker 與 Infrastructure 設定
|
||||
|
||||
- [x] 2.1 [後端] 在 `docker-compose.yml` 新增 `reverb` service:複用 `cfdive-platform` image,`command: php artisan reverb:start --host=0.0.0.0 --port=8080 --debug`,連接 `cfdive-network` 與 `proxy_net`,`restart: unless-stopped`,`depends_on: app`
|
||||
- [x] 2.2 [Infrastructure] 在 DNS 新增 A Record:`ws.hank-space.com` → VPS IP
|
||||
- [x] 2.3 [Infrastructure] 在 Nginx Proxy Manager 新增 Proxy Host:Domain `ws.hank-space.com`,Forward 至 `reverb:8080`,啟用 WebSocket support,申請 SSL 憑證
|
||||
- [x] 2.4 [後端] 執行 `docker-compose up --build reverb` 驗證 Reverb 容器啟動正常
|
||||
|
||||
## 3. 資料庫
|
||||
|
||||
- [x] 3.1 [後端] 建立 migration `create_booking_messages_table`:欄位 `id`、`booking_id`(FK)、`sender_id`(FK users)、`sender_type`(enum: member/provider)、`type`(enum: text/image)、`content`(text)、`read_at`(timestamp nullable)、`timestamps`;加 index `(booking_id, created_at)`
|
||||
- [x] 3.2 [後端] 執行 `php artisan migrate` 並確認資料表建立
|
||||
|
||||
## 4. 後端 Model、Channel 與 Event
|
||||
|
||||
- [x] 4.1 [後端] 建立 `app/Models/BookingMessage.php`:定義 `$fillable`、`booking()` belongsTo、`sender()` morphTo 或一般 belongsTo(依 sender_type 切換)
|
||||
- [x] 4.2 [後端] 建立 `app/Broadcasting/BookingPresenceChannel.php`:實作 `join()` 方法,eager load `schedule`;Member 驗證 `booking->member_id === $user->id`,Provider 驗證 `booking->schedule->provider_id === $user->id`;`confirmed` 以外狀態返回 `false`;授權成功回傳 `['user_id', 'user_type', 'name']`
|
||||
- [x] 4.3 [後端] 在 `routes/channels.php` 註冊 `Broadcast::channel('presence-booking.{bookingId}', BookingPresenceChannel::class)`
|
||||
- [x] 4.4 [後端] 建立 `app/Events/MessageSent.php`:implements `ShouldBroadcastNow`(同步廣播,MVP 不走 queue),`broadcastOn()` 返回 `new PresenceChannel("presence-booking.{$this->message->booking_id}")`,`broadcastWith()` 回傳 `id`、`sender_id`、`sender_type`、`type`、`content`、`created_at`
|
||||
- [x] 4.5 [後端] 建立 `app/Events/MessageRead.php`:implements `ShouldBroadcastNow`,廣播 `reader_type`、`last_read_message_id`
|
||||
|
||||
## 5. 後端 API
|
||||
|
||||
- [x] 5.1 [後端] 建立 `app/Http/Controllers/BookingMessageController.php`,方法:`index`(取得歷史)、`store`(發送文字/圖片)、`markRead`(標記已讀)
|
||||
- [x] 5.2 [後端] `index` 方法:驗證使用者為參與方,回傳 `booking.messages()->orderBy('created_at')->get()`,`confirmed` 與 `completed` 均可讀取
|
||||
- [x] 5.3 [後端] `store` 方法:驗證 booking status 為 `confirmed`(其他狀態回傳 403);text 訊息驗證 `content` 非空;image 訊息驗證 `file`(mimes: jpg,png,gif,webp,max: 10240KB),存至 `Storage::disk('public')`,路徑 `booking-images/{uuid}.{ext}`,`content` 存完整 URL(`Storage::url(...)`);建立 `BookingMessage`;dispatch `MessageSent` event(ShouldBroadcastNow)
|
||||
- [x] 5.4 [後端] `markRead` 方法:更新「對方發送」且「id ≤ last_read_message_id」且「read_at IS NULL」的訊息之 `read_at`;booking status 為 `confirmed` 時 dispatch `MessageRead` event;`completed` 時只更新 DB,不 broadcast(頻道已關閉)
|
||||
- [x] 5.5 [後端] 在 `routes/api.php` 新增路由(member 與 provider 各自的 auth middleware group):`GET /api/bookings/{booking}/messages`、`POST /api/bookings/{booking}/messages`、`POST /api/bookings/{booking}/messages/read`
|
||||
|
||||
## 6. 前端 Echo 初始化
|
||||
|
||||
- [x] 6.1 [前端] 在 `frontend/src/plugins/echo.js`(新建)初始化 `Laravel Echo`:`broadcaster: 'reverb'`,`wsHost: ws.hank-space.com`,`wsPort: 443`,`wssPort: 443`,`forceTLS: true`,`enabledTransports: ['ws', 'wss']`
|
||||
- [x] 6.2 [前端] 在 `frontend/src/plugins/echo.js` 支援雙 token:Member 用 `localStorage.getItem('token')`,Coach 用 `localStorage.getItem('coach_token')`,依當前角色動態設定 Authorization header;`authEndpoint` 使用完整 URL:`${import.meta.env.VITE_API_URL}/broadcasting/auth`(跨 domain 不可使用相對路徑)
|
||||
- [x] 6.3 [前端] 在 `main.js` 掛載 Echo plugin
|
||||
|
||||
## 7. 前端訊息元件
|
||||
|
||||
- [x] 7.1 [前端] 建立 `frontend/src/components/BookingChat.vue`:props 接收 `bookingId`、`bookingStatus`、`currentUserType`
|
||||
- [x] 7.2 [前端] `BookingChat.vue` 訂閱 `presence-booking.{bookingId}`,處理 `.here()`(顯示在線狀態)、`.joining()`、`.leaving()`
|
||||
- [x] 7.3 [前端] `BookingChat.vue` 監聽 `MessageSent` event,新訊息即時 append 到訊息列表
|
||||
- [x] 7.4 [前端] `BookingChat.vue` 監聽 `MessageRead` event,更新訊息「已讀」標記
|
||||
- [x] 7.5 [前端] `BookingChat.vue` 實作文字輸入框與送出按鈕,call `POST /api/bookings/{id}/messages`
|
||||
- [x] 7.6 [前端] `BookingChat.vue` 實作圖片上傳按鈕(`<input type="file" accept="image/*">`),以 `FormData` 送出
|
||||
- [x] 7.7 [前端] `BookingChat.vue` 在 `confirmed` 狀態顯示輸入區;`completed` 狀態顯示「對話已封存」提示;其他狀態不渲染元件
|
||||
- [x] 7.8 [前端] 元件 mount 時呼叫 `GET /api/bookings/{id}/messages` 載入歷史訊息,並送出 `markRead`
|
||||
|
||||
## 8. 前端嵌入預約詳情頁
|
||||
|
||||
- [x] 8.1 [前端] 在 Member 的預約詳情頁(`src/views/MyBookingsView.vue` 展開區塊)嵌入 `<BookingChat>` 元件
|
||||
- [x] 8.2 [前端] 在 Coach 的預約管理頁(`src/views/coach/BookingManagerView.vue`)嵌入 `<BookingChat>` 元件,點擊「訊息」按鈕展開
|
||||
- [x] 8.3 [前端] 在預約列表卡片顯示未讀訊息角標:實作 `GET /api/bookings/messages/unread-counts`(一次回傳所有 booking 的未讀數,非逐筆呼叫);建立 `useBookingUnreadCounts` composable(60s 輪詢);`BookingChat` 加 `emit('read')` 讓父層即時清零角標
|
||||
|
||||
## 8.5 訊息通知系統(實作中追加,超出原始 scope)
|
||||
|
||||
- [x] 8.5.1 [後端] 建立 `NewBookingMessageNotification`:`database` channel only(不寄信);`title` 含寄件方姓名(provider 加「教練」前綴);**不走 Queue**(同步寫入,確保廣播前 DB 已有資料)
|
||||
- [x] 8.5.2 [後端] 建立 `NotificationCreated` event:`ShouldBroadcastNow`,廣播至 `private-App.Models.User.{id}`(複用 channels.php 已有的授權);`broadcastAs()` = `'notification.created'`
|
||||
- [x] 8.5.3 [後端] `BookingMessageController::store()` 在 broadcast `MessageSent` 後,同步 notify receiver,再 broadcast `NotificationCreated`
|
||||
- [x] 8.5.4 [前端] `notifications.js` store 新增 `startRealtime(userId)` / `stopRealtime()`:訂閱 `private-App.Models.User.{id}`,收到 `notification.created` 立刻呼叫 `fetchUnreadCount()`
|
||||
- [x] 8.5.5 [前端] `auth.js` 與 `coachAuth.js` 的 `init()` / `setAuth()` / `logout()` 均呼叫 `startRealtime` / `stopRealtime`
|
||||
- [x] 8.5.6 [前端] `BookingChat.vue` 新增瀏覽器通知(Web Notifications API):mount 時請求權限;收到對方 `MessageSent` 且 `document.hidden` 時推送;`tag: booking-chat-{id}` 防止同一預約疊加通知
|
||||
|
||||
## 9. 整合測試與手動驗證
|
||||
|
||||
- [x] 9.1 [整合測試] 驗證 `/broadcasting/auth` 端點可存取(不是 404):分別用 member token 與 coach token 送出請求,確認路由已正確註冊且 Sanctum middleware 生效;合法參與方回傳 200,非參與方回傳 403,非 confirmed 狀態回傳 403
|
||||
- [x] 9.2 [整合測試] 驗證 `POST /api/bookings/{id}/messages`:text 訊息成功建立且廣播;image 上傳成功且 URL 可存取;invalid 檔案回傳 422
|
||||
- [x] 9.3 [整合測試] 驗證 `markRead`:己方訊息不更新;對方訊息 `read_at` 被設定;`MessageRead` 廣播觸發
|
||||
- [x] 9.4 [整合測試] 驗證封存:`completed` 預約 POST 訊息回傳 403,GET 歷史正常回傳
|
||||
- [x] 9.5 [手動驗證] 開兩個瀏覽器分別登入 Member 與 Coach,確認訊息即時雙向傳達、在線狀態顯示正確、已讀回執正常觸發
|
||||
- [x] 9.6 [手動驗證] 測試圖片訊息:上傳圖片後對方即時看到圖片
|
||||
- [x] 9.7 [手動驗證] 關閉一個視窗,確認另一端顯示「對方已離線」
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-06-01
|
||||
@@ -0,0 +1,97 @@
|
||||
## Context
|
||||
|
||||
CFDivePlatform 使用 Sanctum Bearer Token 認證,token 儲存於前端 localStorage。安全評估發現三個在當前架構下可直接修復的高危漏洞,不需要切換認證機制。此 change 針對性修補,範圍刻意控制在最小改動。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Token 設定有限期,降低洩漏後的持續暴露時間
|
||||
- 消除 Google OAuth redirect URL 中的 token 洩漏路徑
|
||||
- 對登入端點加入基本的暴力破解防護
|
||||
|
||||
**Non-Goals:**
|
||||
- 不切換至 HttpOnly Cookie(需要 HTTPS、CSRF token、OAuth 重構,成本過高)
|
||||
- 不修改既有 token 的儲存位置(localStorage → sessionStorage 留 P1)
|
||||
- 不實作 token refresh / silent renew 機制(留 P1)
|
||||
- 不清除資料庫中現存的永不過期 token(`expires_at = null` 的舊記錄接受自然淘汰;`prune-expired` 只刪 `expires_at` 已逾期的記錄,兩者不衝突)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Token 過期時間設為 7 天
|
||||
|
||||
**選擇**:`sanctum.expiration = 10080`(分鐘)
|
||||
|
||||
**原因**:7 天符合「使用者每週至少登入一次」的合理假設,過短(如 1 天)會顯著提高重新登入頻率影響 UX,過長則縮減不了多少攻擊視窗。
|
||||
|
||||
**替代方案考慮**:
|
||||
- 1 天:洩漏視窗最小,但 UX 衝擊大
|
||||
- 30 天:常見電商慣例,但本平台含金流元素不適用
|
||||
- 無限:現狀,排除
|
||||
|
||||
**副作用**:`sanctum.expiration` 只影響新建立的 token,既有 `personal_access_tokens` 中 `expires_at = null` 的記錄不受影響。可接受——舊 token 在使用者下次登入後自然被新 token 取代。
|
||||
|
||||
**清理機制**:啟用 expiration 後過期 token 不會自動從資料庫刪除,需搭配 `sanctum:prune-expired --hours=168` 排程(`routes/console.php`)每日清理,避免 `personal_access_tokens` 表持續膨脹。
|
||||
|
||||
---
|
||||
|
||||
### 2. Google OAuth redirect 改用 URL Fragment
|
||||
|
||||
**選擇**:`redirect(config('app.frontend_url') . '/auth/callback#token=' . $token)`
|
||||
|
||||
**原因**:
|
||||
- URL fragment(`#` 後的部分)依 HTTP 規範不會被瀏覽器送到 server,因此**不會出現在 Nginx/server log**
|
||||
- Fragment 不會包含在 `Referer` header 中
|
||||
- Fragment 不會出現在瀏覽器歷史的完整記錄中(部分瀏覽器歷史仍記錄 fragment,但不在伺服器端)
|
||||
- 前端改動僅一行:`route.query.token` → `new URLSearchParams(window.location.hash.substring(1)).get('token')`
|
||||
|
||||
**替代方案考慮**:
|
||||
- **後端 POST 回 token**:OAuth provider 只能 redirect,無法直接 POST
|
||||
- **server-side session 中繼**:後端 redirect 到 `/auth/callback?code=<random>` 再由前端換 token——更安全但需要 server-side session store,架構複雜度提高
|
||||
- **保持 query string**:現狀,洩漏路徑未消除,排除
|
||||
|
||||
**選擇 fragment 而非 server-side session 的原因**:fragment 方案完全消除 server log 洩漏問題,且改動最小,對此平台規模足夠。
|
||||
|
||||
---
|
||||
|
||||
### 3. Rate Limiting 使用 Laravel 內建 throttle middleware
|
||||
|
||||
**選擇**:
|
||||
- Member / Provider 登入:throttle:5,1
|
||||
- Admin 登入:throttle:3,1(帳號權限較高,適當收緊)
|
||||
|
||||
**原因**:
|
||||
- Laravel 11 throttle middleware 基於 IP,不需要額外套件
|
||||
- 觸發時自動回傳 HTTP 429 並帶 `Retry-After` header
|
||||
- 5次/分鐘對正常使用者幾乎無感,但讓暴力破解的時間成本從秒級提高到分鐘級
|
||||
|
||||
**替代方案考慮**:
|
||||
- `throttle:10,1`:太寬鬆,對所有角色排除
|
||||
- `throttle:3,1` for member/provider:可能誤傷快速填錯密碼的正常使用者,故 member/provider 維持 5 次;admin 因帳號影響範圍大,3 次的誤傷成本可接受
|
||||
- Progressive delay(每次失敗增加等待時間):需要額外實作,P0 不引入複雜度
|
||||
|
||||
**僅套用登入端點**:register 端點不加(新帳號建立的攻擊面不同,留 P1)
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **既有 token 不過期**:`expiration` 改動不回溯,需接受現有洩漏的舊 token 持續有效直到使用者重新登入。風險可接受,因為:(1) 此為首次設定,無歷史洩漏事件;(2) 使用者正常使用就會刷新。
|
||||
- **`personal_access_tokens` 表膨脹**:啟用 expiration 後過期記錄不自動清除,需排程 `sanctum:prune-expired` → 已列入 tasks。
|
||||
- **`env()` 在 config cache 下失效**:`SocialAuthController` 原本使用 `env('FRONTEND_URL')`,執行 `php artisan config:cache` 後(production 必做)`env()` 回傳 null,導致 redirect 到錯誤 URL。此 change 一併修正為 `config('app.frontend_url')`(`config/app.php` 已有此鍵)。
|
||||
- **fragment 在部分瀏覽器歷史仍可見**:Chrome DevTools Application → Session History 仍記錄完整 URL 含 fragment,但這是本地攻擊面(攻擊者需存取使用者設備),遠低於 server log 洩漏的遠端攻擊面。
|
||||
- **IP-based throttle 可被繞過**:攻擊者可換 IP 或使用代理。此方案擋的是基本掃描攻擊,不是針對性攻擊。P1 可加 account-level lockout。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 套用 `config/sanctum.php` 修改,重啟 PHP-FPM(或 Docker container)
|
||||
2. 在 `routes/console.php` 新增 `Schedule::command('sanctum:prune-expired --hours=168')->daily();`(無需重啟,下次排程週期生效)
|
||||
3. 套用 `routes/api.php` 修改,無需重啟(路由快取需清除:`php artisan route:clear`)
|
||||
4. 套用後端 `SocialAuthController.php` 修改
|
||||
5. 套用前端 `AuthCallbackView.vue` 修改,重新 build frontend(`npm run build`)
|
||||
6. 驗證:手動測試 Google OAuth 登入流程、登入頻率限制觸發、手動執行 `php artisan sanctum:prune-expired --hours=168` 確認排程指令可正常執行
|
||||
|
||||
**Rollback**:各項變更相互獨立,可逐項還原。Token 過期設定還原後,新建 token 重新改為不過期(舊的已過期的 token 無法恢復)。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Coach 登入端點 `/api/provider/login` 是否也需要 rate limiting?**(已包含在此 change 中,與 member 同風險級別,統一採用 `throttle:5,1`)
|
||||
- **Admin 登入是否需要更嚴格的限制?** → **決策:改為 `throttle:3,1`,在此 change 實作。** 管理員帳號一旦被暴力破解,影響範圍遠大於一般使用者(可存取所有用戶資料、審核功能),更嚴格的限制可接受,正常管理員操作不太可能在 1 分鐘內連續嘗試 3 次以上。
|
||||
- **是否需要對 Google OAuth callback 加入 state parameter 驗證(CSRF for OAuth)?** → **確認留 P1。** 風險評估:`stateless()` 模式下缺少 state 驗證,理論上存在 CSRF-for-OAuth 攻擊面(攻擊者偽造 callback 讓受害者綁定攻擊者的 Google 帳號)。緩解因素:(1) Google OAuth 本身要求使用者主動授權,不能靜默觸發;(2) 攻擊者需控制受害者瀏覽器的 callback 請求,難度高;(3) 平台目前無高價值資產(金流未上線)。P1 實作時加入 `state` parameter 並在 session 中驗證。
|
||||
@@ -0,0 +1,29 @@
|
||||
## Why
|
||||
|
||||
目前認證機制存在三個高危漏洞:Token 永不過期(一旦洩漏攻擊者永久持有存取權)、Google OAuth callback 將 Sanctum token 附在 URL query string(出現在 server log、瀏覽器歷史、Referer header),以及登入端點無頻率限制(暴力破解無防護)。三項缺陷均可在不入侵伺服器的前提下被利用。
|
||||
|
||||
## What Changes
|
||||
|
||||
- `config/sanctum.php`:將 `expiration` 從 `null` 改為 `10080`(7 天 × 24 小時 × 60 分鐘)
|
||||
- `SocialAuthController::handleGoogleCallback()`:redirect URL 改用 `#token=` fragment 取代 `?token=` query string
|
||||
- `AuthCallbackView.vue`:改從 `window.location.hash` 讀取 token,不再使用 `route.query.token`
|
||||
- `routes/api.php`:對 member / provider / admin 三個登入端點套用 `throttle:5,1` middleware
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `login-rate-limiting`: 登入端點的請求頻率限制——每個 IP 每分鐘最多 5 次,超過回傳 429
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `provider-auth`: 新增登入 rate limiting 場景;新增 token 有效期行為(7 天後失效)
|
||||
- `admin-auth`: 新增登入 rate limiting 場景;新增 token 有效期行為(7 天後失效)
|
||||
- `member-portal-ui`: Google OAuth callback 行為變更——token 改由 URL fragment 傳遞,前端改從 hash 讀取
|
||||
|
||||
## Impact
|
||||
|
||||
- **後端**:`config/sanctum.php`、`app/Http/Controllers/API/SocialAuthController.php`、`routes/api.php`
|
||||
- **前端**:`frontend/src/views/AuthCallbackView.vue`
|
||||
- **現有 token**:`expiration` 設定僅影響**新建立**的 token,既有的 `personal_access_tokens` 不受影響(`expires_at` 欄位為 null 的舊 token 仍長期有效),需另行清除或接受自然淘汰
|
||||
- **無破壞性變更**:API 介面、response 格式、前端路由均不改變
|
||||
@@ -0,0 +1,29 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 管理員登入
|
||||
後端 SHALL 提供 `POST /api/admin/login`(現有 AuthController 方法),驗證 email/password 並確認 role=admin,回傳有效期 7 天的 Bearer token。
|
||||
|
||||
#### Scenario: 正確帳密登入
|
||||
- **WHEN** 管理員送出正確 email 與 password
|
||||
- **THEN** 回傳 HTTP 200,`{ status: true, data: { user, token, token_type: "Bearer" } }`
|
||||
|
||||
#### Scenario: 非 admin 角色帳號嘗試登入
|
||||
- **WHEN** role 非 admin 的帳號嘗試呼叫此端點
|
||||
- **THEN** 回傳 HTTP 401,`{ status: false, message: "電子郵件或密碼錯誤" }`
|
||||
|
||||
#### Scenario: 超過登入頻率限制
|
||||
- **WHEN** 同一 IP 在 1 分鐘內送出超過 3 次登入請求
|
||||
- **THEN** 回傳 HTTP 429,帶有 `Retry-After` header
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 管理員 Bearer Token 有效期
|
||||
後端 SHALL 發行有效期為 7 天的管理員 Bearer Token,過期後需重新登入。
|
||||
|
||||
#### Scenario: Token 過期後管理員請求被拒絕
|
||||
- **WHEN** 管理員使用已過期(超過 7 天)的 token 送出 API 請求
|
||||
- **THEN** 回傳 HTTP 401,token 視為無效
|
||||
|
||||
#### Scenario: 有效期內 token 正常通過認證
|
||||
- **WHEN** 管理員使用未過期的 token 送出 API 請求
|
||||
- **THEN** 請求正常通過認證,回傳對應資源
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 登入頻率限制
|
||||
後端 SHALL 對所有登入端點套用 IP-based 頻率限制,超過限制時回傳 HTTP 429。Member 與 Provider 每 IP 每分鐘最多 5 次;Admin 因影響範圍更廣,限制為每 IP 每分鐘最多 3 次。
|
||||
|
||||
#### Scenario: Member / Provider 正常登入不受影響
|
||||
- **WHEN** 同一 IP 在 1 分鐘內對 `/api/member/login` 或 `/api/provider/login` 送出 5 次以內的請求
|
||||
- **THEN** 請求正常處理,回傳對應的登入結果(200 成功或 401 失敗)
|
||||
|
||||
#### Scenario: Member / Provider 超過頻率限制
|
||||
- **WHEN** 同一 IP 在 1 分鐘內送出第 6 次 member 或 provider 登入請求
|
||||
- **THEN** 回傳 HTTP 429,並帶有 `Retry-After` header 指示等待時間
|
||||
|
||||
#### Scenario: Admin 超過頻率限制
|
||||
- **WHEN** 同一 IP 在 1 分鐘內送出第 4 次 `/api/admin/login` 請求
|
||||
- **THEN** 回傳 HTTP 429,並帶有 `Retry-After` header 指示等待時間
|
||||
|
||||
#### Scenario: 頻率限制重置
|
||||
- **WHEN** 頻率限制觸發後等待 1 分鐘
|
||||
- **THEN** 該 IP 的請求計數重置,可再次正常送出登入請求
|
||||
@@ -0,0 +1,37 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 登入頁
|
||||
前端 SHALL 提供 `/login` 頁面,供會員以 email/password 登入,以及 Google OAuth 登入入口。
|
||||
|
||||
#### Scenario: Email/Password 登入成功
|
||||
- **WHEN** 使用者填入正確的 email 與 password 並送出
|
||||
- **THEN** 呼叫 `POST /api/member/login`,儲存回傳的 token 至 localStorage,導航至 `/courses`
|
||||
|
||||
#### Scenario: 登入失敗
|
||||
- **WHEN** 使用者填入錯誤的 email 或 password
|
||||
- **THEN** 頁面顯示錯誤訊息,不跳轉
|
||||
|
||||
#### Scenario: Google OAuth 登入
|
||||
- **WHEN** 使用者點擊「以 Google 登入」按鈕
|
||||
- **THEN** 瀏覽器導航至後端 `GET /api/auth/google/redirect`,開始 OAuth 流程
|
||||
|
||||
#### Scenario: 超過登入頻率限制
|
||||
- **WHEN** 同一 IP 在 1 分鐘內送出超過 5 次登入請求
|
||||
- **THEN** 前端顯示適當的錯誤訊息(對應後端回傳的 HTTP 429)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Google OAuth Callback 處理
|
||||
前端 SHALL 在 `/auth/callback` 路由讀取 URL fragment(`#token=<value>`)取得 Sanctum token,完成 OAuth 登入後將 token 存入 localStorage 並導航至 `/courses`。token 不得透過 URL query string 傳遞。
|
||||
|
||||
#### Scenario: OAuth callback 成功取得 token
|
||||
- **WHEN** 後端 OAuth callback redirect 到 `/auth/callback#token=<token>`
|
||||
- **THEN** 前端從 `window.location.hash` 解析 token,呼叫 `/api/member/profile` 取得使用者資料,呼叫 `auth.setAuth()` 儲存認證狀態,並導航至 `/courses`
|
||||
|
||||
#### Scenario: OAuth callback 缺少 token
|
||||
- **WHEN** redirect 到 `/auth/callback` 但 hash 中無 `token` 參數
|
||||
- **THEN** 前端導航至 `/login?error=oauth_failed`,顯示錯誤訊息
|
||||
|
||||
#### Scenario: URL 中不留存 token
|
||||
- **WHEN** callback 頁面成功處理 token 後
|
||||
- **THEN** 瀏覽器網址列不顯示 token(使用 `history.replaceState` 清除 hash)
|
||||
@@ -0,0 +1,33 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 教練帳號登入
|
||||
後端 SHALL 提供 `POST /api/provider/login`,驗證 email/password 並回傳 Sanctum Bearer token,僅限 role=provider 帳號。回傳的 token 有效期為 7 天。
|
||||
|
||||
#### Scenario: 正確帳密登入成功
|
||||
- **WHEN** 教練送出正確的 email 與 password
|
||||
- **THEN** 回傳 HTTP 200,包含 `{ status: true, data: { user, token, token_type: "Bearer" } }`
|
||||
|
||||
#### Scenario: 錯誤帳密登入失敗
|
||||
- **WHEN** 教練送出錯誤的 email 或 password
|
||||
- **THEN** 回傳 HTTP 401,`{ status: false, message: "帳號或密碼錯誤" }`
|
||||
|
||||
#### Scenario: 會員帳號無法用教練登入
|
||||
- **WHEN** role=member 的帳號嘗試呼叫 `/api/provider/login`
|
||||
- **THEN** 回傳 HTTP 403,`{ status: false, message: "此帳號非教練角色" }`
|
||||
|
||||
#### Scenario: 超過登入頻率限制
|
||||
- **WHEN** 同一 IP 在 1 分鐘內送出超過 5 次登入請求
|
||||
- **THEN** 回傳 HTTP 429,帶有 `Retry-After` header
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Bearer Token 有效期
|
||||
後端 SHALL 發行有效期為 7 天的 Sanctum Bearer Token,過期後使用者必須重新登入。
|
||||
|
||||
#### Scenario: Token 過期後請求被拒絕
|
||||
- **WHEN** 教練使用已過期(超過 7 天)的 token 送出 API 請求
|
||||
- **THEN** 回傳 HTTP 401,token 視為無效
|
||||
|
||||
#### Scenario: 有效期內 token 正常運作
|
||||
- **WHEN** 教練使用未過期的 token 送出 API 請求
|
||||
- **THEN** 請求正常通過認證
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user