feat: 即時預約聊天室(realtime booking chat)

## 新增功能
- 會員與教練在 confirmed 預約期間可互傳文字與圖片訊息
- Presence Channel 顯示對方在線狀態(即時加入/離線)
- 已讀回執:對方讀取訊息後即時更新
- 預約完成(completed)後訊息封存唯讀
- 圖片上傳使用 intervention/image 處理(移除 EXIF、限制尺寸、強制重新編碼)

## 通知系統
- 收到新訊息時 Bell Icon 即時更新(NotificationCreated via private channel)
- 預約列表卡片顯示未讀角標(GET /api/bookings/messages/unread-counts 批次查詢)
- 瀏覽器分頁在背景時推送 Web Notification

## 基礎建設
- 引入 Laravel Reverb 作為自架 WebSocket 伺服器
- docker-compose 新增 reverb service(連接 proxy_net,供 NPM 代理)
- 前端安裝 laravel-echo + pusher-js,初始化 Echo plugin
- Dockerfile 補 GD JPEG/WebP/FreeType 支援

## 清理
- 移除 test_broadcast.php 與 resources/js/echo.js(Blade 殘留)
- 移除 /ping、/testpost 測試路由
- channels.php 改用 class-based 授權語法,移除 debug log
- BookingChat.vue otherType 提取為 computed 消除重複
- docker-entrypoint.sh 健康檢查改用 env var 取代硬編碼密碼

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 03:59:14 +08:00
parent cc010b5c83
commit ab24f210a6
39 changed files with 2842 additions and 62 deletions
+5 -1
View File
@@ -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 \
@@ -21,7 +24,8 @@ RUN apt-get update && apt-get install -y \
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 執行文件
@@ -0,0 +1,31 @@
<?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->provider_id === $user->id;
if (!$isMember && !$isProvider) {
return false;
}
return [
'user_id' => $user->id,
'user_type' => $user->role,
'name' => $user->name,
];
}
}
+40
View File
@@ -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,
];
}
}
+41
View File
@@ -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(),
];
}
}
+32
View File
@@ -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 channelchannels.php 已有 auth
return [new PrivateChannel("App.Models.User.{$this->userId}")];
}
public function broadcastAs(): string
{
return 'notification.created';
}
// 不需要 payload,前端收到後直接呼叫 fetchUnreadCount()
public function broadcastWith(): array
{
return [];
}
}
@@ -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
View File
@@ -48,4 +48,9 @@ class Booking extends Model
{
return $this->belongsTo(User::class, 'member_id');
}
public function messages()
{
return $this->hasMany(BookingMessage::class);
}
}
+31
View File
@@ -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');
}
}
@@ -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',
];
}
}
+1
View File
@@ -9,6 +9,7 @@ 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) {
+2
View File
@@ -7,7 +7,9 @@
"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",
Generated
+1051 -2
View File
File diff suppressed because it is too large Load Diff
+82
View File
@@ -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',
],
],
];
+102
View File
@@ -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),
],
],
],
],
];
@@ -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
View File
@@ -48,6 +48,8 @@ services:
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/
@@ -69,9 +71,15 @@ services:
dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL:-https://api.hank-spack.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
@@ -156,6 +164,37 @@ services:
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
+23 -7
View File
@@ -28,7 +28,7 @@ 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
if mysqladmin ping -h"db" -u"${DB_USERNAME:-cfdiveuser}" -p"${DB_PASSWORD}" --silent 2>/dev/null; then
echo "✅ MySQL 服務已準備就緒"
return 0
fi
@@ -36,7 +36,7 @@ wait_for_mysql() {
# 備用檢查方法
if php -r "
try {
\$pdo = new PDO('mysql:host=db;port=3306', 'cfdiveuser', '**REMOVED**');
\$pdo = new PDO('mysql:host=db;port=3306', getenv('DB_USERNAME') ?: 'cfdiveuser', getenv('DB_PASSWORD') ?: '');
echo 'PHP-PDO-OK';
exit(0);
} catch(Exception \$e) {
@@ -80,12 +80,28 @@ else
echo "✅ .env 檔案已存在"
fi
# 更新環境變數以確保正確配置
# 更新環境變數以確保正確配置(用 PHP 安全處理含特殊字元的密碼)
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
cat > /tmp/update_env.php << 'PHPEOF'
<?php
$env = file_get_contents('/var/www/.env');
$map = [
'DB_HOST' => 'db',
'DB_USERNAME' => (getenv('DB_USERNAME') ?: 'cfdiveuser'),
'DB_DATABASE' => (getenv('DB_DATABASE') ?: 'CFDivePlatform'),
'DB_PASSWORD' => (getenv('DB_PASSWORD') ?: ''),
];
foreach ($map as $key => $val) {
$env = preg_replace_callback(
'/^' . preg_quote($key, '/') . '=.*$/m',
function() use ($key, $val) { return $key . '=' . $val; },
$env
);
}
file_put_contents('/var/www/.env', $env);
echo "✅ DB config updated\n";
PHPEOF
php /tmp/update_env.php
# 執行遷移(如果數據庫已準備好)
echo "🗄️ 執行數據庫遷移..."
+9
View File
@@ -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
+230
View File
@@ -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
}
}
}
+2
View File
@@ -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"
},
+274
View File
@@ -0,0 +1,274 @@
<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
try {
await axiosInstance.value.post(`/bookings/${props.bookingId}/messages`, {
type: 'text',
content,
})
} catch (e) {
textInput.value = content
} 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) => {
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) {
// 對方傳來的訊息:推瀏覽器通知、刷新 bell badge
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()
}
})
}
})
}
onMounted(async () => {
await requestBrowserNotificationPermission()
await loadHistory()
if (isConfirmed.value) {
subscribeChannel()
}
})
onUnmounted(() => {
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>
@@ -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 }
}
+2
View File
@@ -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')
+36
View File
@@ -0,0 +1,36 @@
import Echo from 'laravel-echo'
import Pusher from 'pusher-js'
window.Pusher = Pusher
function getAuthToken() {
return localStorage.getItem('coach_token') || localStorage.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,讓 presence channel 授權帶正確 token
export function updateEchoToken() {
const token = getAuthToken()
echo.options.auth.headers.Authorization = `Bearer ${token}`
// 重新連線讓新 token 生效
echo.disconnect()
echo.connect()
}
export default echo
+11 -3
View File
@@ -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)
@@ -15,7 +16,9 @@ export const useAuthStore = defineStore('auth', () => {
if (saved) {
token.value = saved
user.value = savedUser ? JSON.parse(savedUser) : null
useNotificationStore().startPolling()
const ns = useNotificationStore()
ns.startPolling()
ns.startRealtime(user.value?.id)
}
}
@@ -24,14 +27,19 @@ export const useAuthStore = defineStore('auth', () => {
token.value = tokenValue
localStorage.setItem('token', tokenValue)
localStorage.setItem('user', JSON.stringify(userData))
useNotificationStore().startPolling()
const ns = useNotificationStore()
ns.startPolling()
ns.startRealtime(userData.id)
updateEchoToken()
}
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')
+11 -3
View File
@@ -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)
@@ -15,7 +16,9 @@ export const useCoachAuthStore = defineStore('coachAuth', () => {
if (savedToken) {
token.value = savedToken
user.value = savedUser ? JSON.parse(savedUser) : null
useNotificationStore().startPolling()
const ns = useNotificationStore()
ns.startPolling()
ns.startRealtime(user.value?.id)
}
}
@@ -24,14 +27,19 @@ export const useCoachAuthStore = defineStore('coachAuth', () => {
token.value = tokenValue
localStorage.setItem('coach_token', tokenValue)
localStorage.setItem('coach_user', JSON.stringify(userData))
useNotificationStore().startPolling()
const ns = useNotificationStore()
ns.startPolling()
ns.startRealtime(userData.id)
updateEchoToken()
}
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')
+28
View File
@@ -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 {
@@ -76,6 +78,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 +137,7 @@ export const useNotificationStore = defineStore('notifications', () => {
unreadCount, notifications, isOpen,
fetchNotifications, fetchUnreadCount,
startPolling, stopPolling,
startRealtime, stopRealtime,
markRead, markAllRead, remove,
}
})
+25
View File
@@ -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
@@ -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,2 @@
schema: spec-driven
created: 2026-05-26
@@ -0,0 +1,34 @@
## Why
預約成立後,會員與教練之間沒有直接溝通管道,只能透過平台外的通訊工具協調集合地點、裝備確認等細節,造成使用流程斷裂。加入即時訊息功能可以將溝通閉環留在平台內,提升黏著度與信任感。
## What Changes
- 新增 `booking_messages` 資料表,儲存文字與圖片訊息,與 `bookings` 一對多關聯
- 引入 **Laravel Reverb** 作為自架 WebSocket 伺服器(docker-compose 新增 `reverb` service
- 每個 confirmed 預約建立一條 **Presence Channel**`presence-booking.{id}`),同時承載訊息推播與在線狀態
- 已讀回執:對方在頻道內且讀取訊息時觸發 `MessageRead` event
- 圖片訊息:透過 HTTP POST 上傳至 Laravel StorageWebSocket 廣播包含圖片 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 端點**:訊息列表、發送文字、上傳圖片、標記已讀
- **修改**`docker-compose.yml``config/broadcasting.php`connection 改為 `reverb`
- **Infrastructure**NPM 新增 `ws.hank-space.com` Proxy HostWebSocket 啟用)、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(只對「對方」的訊息觸發)
@@ -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`
#### 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`
- [ ] 2.2 [Infrastructure] 在 DNS 新增 A Record`ws.hank-space.com` → VPS IP
- [ ] 2.3 [Infrastructure] 在 Nginx Proxy Manager 新增 Proxy HostDomain `ws.hank-space.com`Forward 至 `reverb:8080`,啟用 WebSocket support,申請 SSL 憑證
- [ ] 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,webpmax: 10240KB),存至 `Storage::disk('public')`,路徑 `booking-images/{uuid}.{ext}``content` 存完整 URL`Storage::url(...)`);建立 `BookingMessage`dispatch `MessageSent` eventShouldBroadcastNow
- [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` 支援雙 tokenMember 用 `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` composable60s 輪詢);`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. 整合測試與手動驗證
- [ ] 9.1 [整合測試] 驗證 `/broadcasting/auth` 端點可存取(不是 404):分別用 member token 與 coach token 送出請求,確認路由已正確註冊且 Sanctum middleware 生效;合法參與方回傳 200,非參與方回傳 403,非 confirmed 狀態回傳 403
- [ ] 9.2 [整合測試] 驗證 `POST /api/bookings/{id}/messages`:text 訊息成功建立且廣播;image 上傳成功且 URL 可存取;invalid 檔案回傳 422
- [ ] 9.3 [整合測試] 驗證 `markRead`:己方訊息不更新;對方訊息 `read_at` 被設定;`MessageRead` 廣播觸發
- [ ] 9.4 [整合測試] 驗證封存:`completed` 預約 POST 訊息回傳 403,GET 歷史正常回傳
- [ ] 9.5 [手動驗證] 開兩個瀏覽器分別登入 Member 與 Coach,確認訊息即時雙向傳達、在線狀態顯示正確、已讀回執正常觸發
- [ ] 9.6 [手動驗證] 測試圖片訊息:上傳圖片後對方即時看到圖片
- [ ] 9.7 [手動驗證] 關閉一個視窗,確認另一端顯示「對方已離線」
+1
View File
@@ -2,3 +2,4 @@ import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
+12 -12
View File
@@ -1,5 +1,6 @@
<?php
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\API\AuthController;
use App\Http\Controllers\API\DivingOfferController;
@@ -14,12 +15,10 @@ use App\Http\Controllers\API\CourseImageController;
use App\Http\Controllers\API\AdminStatsController;
use App\Http\Controllers\API\AdminUserController;
use App\Http\Controllers\API\AdminOfferController;
use App\Http\Controllers\API\BookingMessageController;
use App\Http\Controllers\API\NotificationController;
// 這裡可以定義 API 路由,例如:
Route::get('/ping', function () {
return response()->json(['message' => 'pong']);
});
Broadcast::routes(['middleware' => ['auth:sanctum']]);
// 潛水課程(公開)
Route::get('/diving-offers', [DivingOfferController::class, 'index']);
@@ -27,14 +26,6 @@ Route::get('/diving-offers/{id}', [DivingOfferController::class, 'show']);
Route::get('/diving-offers/{id}/schedules', [ScheduleController::class, 'publicList']);
Route::get('/diving-offers/{id}/reviews', [ReviewController::class, 'publicList']);
// 你可以在這裡繼續新增 API 路由
Route::post('/testpost', function () {
$data = request()->all(); // 取得所有POST資料(array
return response()->json([
'data' => $data,
]);
});
// 會員註冊/登入
Route::post('/member/register', [AuthController::class, 'registerMember']);
Route::post('/member/login', [AuthController::class, 'loginMember']);
@@ -153,6 +144,15 @@ Route::middleware('auth:sanctum')->prefix('notifications')->group(function () {
Route::delete('/{id}', [NotificationController::class, 'destroy']);
});
// 即時訊息(Member + Provider 共用,依 booking 參與方驗證)
Route::middleware('auth:sanctum')->group(function () {
// unread-counts 必須在 {booking} 之前,否則會被 route model binding 吃掉
Route::get('/bookings/messages/unread-counts', [BookingMessageController::class, 'unreadCounts']);
Route::get('/bookings/{booking}/messages', [BookingMessageController::class, 'index']);
Route::post('/bookings/{booking}/messages', [BookingMessageController::class, 'store']);
Route::post('/bookings/{booking}/messages/read', [BookingMessageController::class, 'markRead']);
});
// 需要認證的通用路由
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
+10
View File
@@ -0,0 +1,10 @@
<?php
use App\Broadcasting\BookingPresenceChannel;
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
return (int) $user->id === (int) $id;
});
Broadcast::channel('booking.{booking}', BookingPresenceChannel::class);