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:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user