diff --git a/app/Broadcasting/BookingPresenceChannel.php b/app/Broadcasting/BookingPresenceChannel.php index 5238301..ede36cc 100644 --- a/app/Broadcasting/BookingPresenceChannel.php +++ b/app/Broadcasting/BookingPresenceChannel.php @@ -16,7 +16,9 @@ class BookingPresenceChannel $booking->loadMissing('schedule'); $isMember = $user->role === 'member' && $booking->member_id === $user->id; - $isProvider = $user->role === 'provider' && $booking->schedule->provider_id === $user->id; + $isProvider = $user->role === 'provider' + && $booking->schedule !== null + && $booking->schedule->provider_id === $user->id; if (!$isMember && !$isProvider) { return false; diff --git a/docker-compose.yml b/docker-compose.yml index 2efed2a..7d32d4a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,7 +70,7 @@ services: context: ./frontend dockerfile: Dockerfile args: - VITE_API_URL: ${VITE_API_URL:-https://api.hank-spack.com} + VITE_API_URL: ${VITE_API_URL:-https://api.hank-space.com} VITE_REVERB_APP_KEY: ${VITE_REVERB_APP_KEY} VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost} VITE_REVERB_PORT: ${VITE_REVERB_PORT:-8085} diff --git a/docker/php/docker-entrypoint.sh b/docker/php/docker-entrypoint.sh index 21c2b53..db9ca67 100644 --- a/docker/php/docker-entrypoint.sh +++ b/docker/php/docker-entrypoint.sh @@ -3,136 +3,61 @@ set -e echo "=== CFDivePlatform 容器初始化開始 ===" -# 檢查目錄結構 -if [ ! -d "/var/www/storage" ]; then - echo "創建 storage 目錄..." - mkdir -p /var/www/storage -fi - -if [ ! -d "/var/www/bootstrap/cache" ]; then - echo "創建 bootstrap/cache 目錄..." - mkdir -p /var/www/bootstrap/cache -fi - -# 設置權限 -echo "設置目錄權限..." +# 確保目錄與權限(php-fpm 啟動前必須完成) +[ ! -d "/var/www/storage" ] && mkdir -p /var/www/storage +[ ! -d "/var/www/bootstrap/cache" ] && mkdir -p /var/www/bootstrap/cache chown -R www-data:www-data /var/www chmod -R 775 /var/www/storage /var/www/bootstrap/cache -# 等待 MySQL 服務啟動 -echo "等待 MySQL 服務啟動..." +# 確保 .env 存在 +if [ ! -f .env ]; then + cp .env.example .env + php artisan key:generate +fi -# 使用更穩定的方法檢查 MySQL 連接 -MAX_TRIES=60 -COUNT=0 +# 強制 DB_HOST=db(Docker service name,不能用 localhost) +php -r " +\$env = file_get_contents('/var/www/.env'); +\$env = preg_replace('/^DB_HOST=.*$/m', 'DB_HOST=db', \$env); +file_put_contents('/var/www/.env', \$env); +" -wait_for_mysql() { - while [ $COUNT -lt $MAX_TRIES ]; do - if mysqladmin ping -h"db" -u"${DB_USERNAME:-cfdiveuser}" -p"${DB_PASSWORD}" --silent 2>/dev/null; then - echo "✅ MySQL 服務已準備就緒" - return 0 - fi - - # 備用檢查方法 - if php -r " - try { - \$pdo = new PDO('mysql:host=db;port=3306', getenv('DB_USERNAME') ?: 'cfdiveuser', getenv('DB_PASSWORD') ?: ''); - echo 'PHP-PDO-OK'; - exit(0); - } catch(Exception \$e) { - exit(1); - } - " 2>/dev/null; then - echo "✅ MySQL 連接成功 (通過 PHP PDO)" - break - fi - - echo "⏳ 等待 MySQL... ($((COUNT+1))/$MAX_TRIES)" +# Composer 依賴(vendor 不存在時才裝,通常已存在於 volume) +if [ -f "composer.json" ] && { [ ! -d "vendor" ] || [ "composer.json" -nt "vendor/autoload.php" ]; }; then + composer install --no-scripts --optimize-autoloader +fi + +# 背景執行:等 MySQL → migration → cache clear → storage link → swagger +# php-fpm 不等這些完成就先啟動,避免重啟時 CORS 502 +( + echo "⏳ [背景] 等待 MySQL..." + COUNT=0 + until mysqladmin ping -h"db" -u"${DB_USERNAME:-cfdiveuser}" -p"${DB_PASSWORD}" --silent 2>/dev/null || [ $COUNT -ge 30 ]; do sleep 2 COUNT=$((COUNT+1)) done - if [ $COUNT -eq $MAX_TRIES ]; then - echo "⚠️ 無法連接到 MySQL,但將繼續啟動服務" + echo "🗄️ [背景] 執行 migration..." + php artisan migrate --force || echo "⚠️ migration 失敗" + + echo "🧹 [背景] 清除 Laravel 緩存..." + php artisan config:clear || true + php artisan cache:clear || true + php artisan route:clear || true + php artisan view:clear || true + + echo "🔗 [背景] storage:link..." + php artisan storage:link --force || true + + if php -r "echo class_exists('L5Swagger\\L5SwaggerServiceProvider') ? 'yes' : 'no';" 2>/dev/null | grep -q 'yes'; then + php artisan l5-swagger:generate || true fi -} -wait_for_mysql + echo "✅ [背景] 初始化完成" +) & -# 檢查並安裝 Composer 依賴 -echo "📦 檢查 Composer 依賴..." -if [ -f "composer.json" ]; then - if [ ! -d "vendor" ] || [ "composer.json" -nt "vendor/autoload.php" ]; then - echo "安裝 Composer 依賴..." - composer install --no-scripts --no-autoloader --optimize-autoloader - composer dump-autoload --optimize - else - echo "✅ Composer 依賴已是最新" - fi -fi - -# 設置 Laravel 環境 -if [ ! -f .env ]; then - echo "🔧 創建 .env 檔案..." - cp .env.example .env - php artisan key:generate -else - echo "✅ .env 檔案已存在" -fi - -# 更新環境變數以確保正確配置(用 PHP 安全處理含特殊字元的密碼) -echo "🔧 更新資料庫配置..." -cat > /tmp/update_env.php << 'PHPEOF' - '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 "🗄️ 執行數據庫遷移..." -if php artisan migrate:status 2>/dev/null; then - php artisan migrate --force || echo "⚠️ 遷移執行遇到問題,但繼續執行" -else - echo "⚠️ 無法檢查遷移狀態,跳過遷移" -fi - -# 清除與優化 Laravel 緩存 -echo "🧹 清除 Laravel 緩存..." -php artisan config:clear || true -php artisan cache:clear || true -php artisan route:clear || true -php artisan view:clear || true - -# 生成 Swagger 文檔(如果可能) -if php -r "echo class_exists('L5Swagger\\L5SwaggerServiceProvider') ? 'yes' : 'no';" 2>/dev/null | grep -q 'yes'; then - echo "📖 生成 API 文檔..." - php artisan l5-swagger:generate || echo "⚠️ API 文檔生成失敗" -fi - -echo "✅ CFDivePlatform 初始化完成!" - -# 建立 storage symlink -echo "🔗 建立 storage symlink..." -php artisan storage:link --force || true - -# 啟動 cron daemon(Laravel Scheduler) -echo "⏰ 啟動 Laravel Scheduler cron..." +# 啟動 cron(Laravel Scheduler) service cron start || cron || true -# 執行傳入的命令 +echo "🚀 啟動 php-fpm..." exec "$@" diff --git a/frontend/src/components/BookingChat.vue b/frontend/src/components/BookingChat.vue index f6b6437..5d6ccaa 100644 --- a/frontend/src/components/BookingChat.vue +++ b/frontend/src/components/BookingChat.vue @@ -4,6 +4,8 @@ import api from '../api/axios' import coachApi from '../api/coachAxios' import echo from '../plugins/echo' import { useNotificationStore } from '../stores/notifications' +import { useAuthStore } from '../stores/auth' +import { useCoachAuthStore } from '../stores/coachAuth' const props = defineProps({ bookingId: { type: Number, required: true }, @@ -21,6 +23,12 @@ const otherUserOnline = ref(false) const channel = ref(null) const notificationStore = useNotificationStore() +const authStore = useAuthStore() +const coachAuthStore = useCoachAuthStore() + +const currentUserId = computed(() => + props.currentUserType === 'provider' ? coachAuthStore.user?.id : authStore.user?.id +) const isConfirmed = computed(() => props.bookingStatus === 'confirmed') const isCompleted = computed(() => props.bookingStatus === 'completed') @@ -91,6 +99,19 @@ async function sendText() { const content = textInput.value.trim() textInput.value = '' isSending.value = true + + const tempId = `_temp_${Date.now()}` + messages.value.push({ + id: tempId, + sender_id: currentUserId.value, + sender_type: props.currentUserType, + type: 'text', + content, + read_at: null, + created_at: new Date().toISOString(), + }) + scrollToBottom() + try { await axiosInstance.value.post(`/bookings/${props.bookingId}/messages`, { type: 'text', @@ -98,6 +119,7 @@ async function sendText() { }) } catch (e) { textInput.value = content + messages.value = messages.value.filter(m => m.id !== tempId) } finally { isSending.value = false } @@ -142,6 +164,16 @@ function subscribeChannel() { if (e.user_type === otherType.value) otherUserOnline.value = e.online }) .listen('.MessageSent', async (e) => { + if (e.sender_type === props.currentUserType) { + // 自己送的訊息:替換樂觀更新的暫存訊息(避免重複) + const tempIdx = messages.value.findIndex( + m => typeof m.id === 'string' && m.id.startsWith('_temp_') && m.content === e.content + ) + if (tempIdx !== -1) { + messages.value[tempIdx] = { id: e.id, sender_id: e.sender_id, sender_type: e.sender_type, type: e.type, content: e.content, read_at: null, created_at: e.created_at } + return + } + } messages.value.push({ id: e.id, sender_id: e.sender_id, @@ -153,7 +185,6 @@ function subscribeChannel() { }) scrollToBottom() if (e.sender_type !== props.currentUserType) { - // 對方傳來的訊息:推瀏覽器通知、刷新 bell badge showBrowserNotification(e) notificationStore.fetchUnreadCount() await markLastRead() @@ -170,15 +201,39 @@ function subscribeChannel() { }) } +function handleReconnected() { + if (!isConfirmed.value) return + echo.leave(`booking.${props.bookingId}`) + channel.value = null + subscribeChannel() +} + +// Fallback:presence channel 失效時,收到 bell 通知也補拉訊息 +async function handleNotificationFallback() { + const lastId = messages.value[messages.value.length - 1]?.id + if (lastId && typeof lastId === 'string') return // 還在樂觀更新中,不拉 + await loadHistory() +} + onMounted(async () => { await requestBrowserNotificationPermission() await loadHistory() if (isConfirmed.value) { subscribeChannel() + echo.connector.pusher.connection.bind('reconnected', handleReconnected) + if (currentUserId.value) { + echo.private(`App.Models.User.${currentUserId.value}`) + .listen('.notification.created', handleNotificationFallback) + } } }) onUnmounted(() => { + echo.connector.pusher.connection.unbind('reconnected', handleReconnected) + if (currentUserId.value) { + echo.private(`App.Models.User.${currentUserId.value}`) + .stopListening('.notification.created', handleNotificationFallback) + } if (channel.value) { channel.value.whisper('presence', { user_type: props.currentUserType, online: false }) echo.leave(`booking.${props.bookingId}`) diff --git a/frontend/src/plugins/echo.js b/frontend/src/plugins/echo.js index 969a2b6..3a06c56 100644 --- a/frontend/src/plugins/echo.js +++ b/frontend/src/plugins/echo.js @@ -27,10 +27,13 @@ const echo = new Echo({ // 登入後更新 auth header,讓 presence channel 授權帶正確 token export function updateEchoToken() { const token = getAuthToken() - echo.options.auth.headers.Authorization = `Bearer ${token}` - // 重新連線讓新 token 生效 - echo.disconnect() - echo.connect() + const bearer = `Bearer ${token}` + echo.options.auth.headers.Authorization = bearer + // 同步更新 Pusher 內部 config(避免 Pusher.js 持有舊的 header copy) + if (echo.connector?.pusher?.config?.auth?.headers) { + echo.connector.pusher.config.auth.headers.Authorization = bearer + } + // 不 disconnect/connect:避免中斷 BookingChat 已訂閱的 presence channel } export default echo diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 35e4fc7..5079603 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -29,8 +29,8 @@ export const useAuthStore = defineStore('auth', () => { localStorage.setItem('user', JSON.stringify(userData)) const ns = useNotificationStore() ns.startPolling() - ns.startRealtime(userData.id) updateEchoToken() + ns.startRealtime(userData.id) } async function logout() { diff --git a/frontend/src/stores/coachAuth.js b/frontend/src/stores/coachAuth.js index dc99e3b..1f1dcad 100644 --- a/frontend/src/stores/coachAuth.js +++ b/frontend/src/stores/coachAuth.js @@ -29,8 +29,8 @@ export const useCoachAuthStore = defineStore('coachAuth', () => { localStorage.setItem('coach_user', JSON.stringify(userData)) const ns = useNotificationStore() ns.startPolling() - ns.startRealtime(userData.id) updateEchoToken() + ns.startRealtime(userData.id) } async function logout() {