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
@@ -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',
];
}
}