From 4cf83375f3404eb0ea38cc091e4ea3bc40603f35 Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 28 May 2026 17:07:29 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=E9=81=BF=E5=85=8D=20entrypoint=20?= =?UTF-8?q?=E8=A6=86=E5=AF=AB=20.env=20=E7=9A=84=20DB=20=E6=86=91=E8=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 僅保留 DB_HOST 強制設為 db(Docker service name), 移除對 DB_USERNAME / DB_DATABASE / DB_PASSWORD 的覆寫。 原本透過 container 環境變數回寫 .env 的機制會在密碼含 特殊字元時因 shell 展開截斷,導致 DB_PASSWORD 被清空。 Co-Authored-By: Claude Sonnet 4.6 --- docker/php/docker-entrypoint.sh | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/docker/php/docker-entrypoint.sh b/docker/php/docker-entrypoint.sh index 21c2b53..2cc814e 100644 --- a/docker/php/docker-entrypoint.sh +++ b/docker/php/docker-entrypoint.sh @@ -86,10 +86,7 @@ cat > /tmp/update_env.php << 'PHPEOF' 'db', - 'DB_USERNAME' => (getenv('DB_USERNAME') ?: 'cfdiveuser'), - 'DB_DATABASE' => (getenv('DB_DATABASE') ?: 'CFDivePlatform'), - 'DB_PASSWORD' => (getenv('DB_PASSWORD') ?: ''), + 'DB_HOST' => 'db', ]; foreach ($map as $key => $val) { $env = preg_replace_callback( -- 2.52.0 From f80319109ce5c947b2483c54cdef40d7ff6c8ab0 Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 28 May 2026 17:23:59 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20entrypoint=20=E5=85=88=E5=95=9F?= =?UTF-8?q?=E5=8B=95=20php-fpm=EF=BC=8Cinit=20=E4=BB=BB=E5=8B=99=E7=A7=BB?= =?UTF-8?q?=E8=87=B3=E8=83=8C=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 將 wait_for_mysql、migration、cache:clear 等耗時操作移至背景 subshell 執行,php-fpm 不再等待它們完成才啟動。 解決每次 docker restart 後 php-fpm 尚未就緒時 Nginx 回傳 502(無 CORS headers),前端看到 CORS error 的問題。 Co-Authored-By: Claude Sonnet 4.6 --- docker/php/docker-entrypoint.sh | 158 +++++++++----------------------- 1 file changed, 43 insertions(+), 115 deletions(-) diff --git a/docker/php/docker-entrypoint.sh b/docker/php/docker-entrypoint.sh index 2cc814e..db9ca67 100644 --- a/docker/php/docker-entrypoint.sh +++ b/docker/php/docker-entrypoint.sh @@ -3,133 +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', -]; -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 "$@" -- 2.52.0 From 6286399a3beb0e60324af70b0e705d70ce8c5def Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 28 May 2026 17:28:17 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20VITE=5FAPI=5FUR?= =?UTF-8?q?L=20=E9=A0=90=E8=A8=AD=E5=80=BC=20typo=EF=BC=88spack=20?= =?UTF-8?q?=E2=86=92=20space=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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} -- 2.52.0 From 1ba4ca893a12086b55604de81c9a31e9034b1e22 Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 28 May 2026 17:46:15 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E5=8D=B3=E6=99=82?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E5=AE=A4=20presence=20channel=20=E9=96=93?= =?UTF-8?q?=E6=AD=87=E6=80=A7=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三個根本原因: 1. updateEchoToken() 呼叫 disconnect/connect 會中斷已訂閱的 presence channel,改為只更新 auth header(不重連)。 同時將 setAuth() 的呼叫順序改為先 updateEchoToken() 再 startRealtime(),確保訂閱時使用最新 token。 2. BookingPresenceChannel::join() 未對 schedule null 做防守, 載入後若 schedule 不存在會 500,導致 auth 靜默失敗。 3. WebSocket 重連後 presence channel 不會自動恢復, 在 BookingChat 加入 reconnected 事件處理,重連後重訂閱。 Co-Authored-By: Claude Sonnet 4.6 --- app/Broadcasting/BookingPresenceChannel.php | 4 +++- frontend/src/components/BookingChat.vue | 9 +++++++++ frontend/src/plugins/echo.js | 11 +++++++---- frontend/src/stores/auth.js | 2 +- frontend/src/stores/coachAuth.js | 2 +- 5 files changed, 21 insertions(+), 7 deletions(-) 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/frontend/src/components/BookingChat.vue b/frontend/src/components/BookingChat.vue index f6b6437..b6ce904 100644 --- a/frontend/src/components/BookingChat.vue +++ b/frontend/src/components/BookingChat.vue @@ -170,15 +170,24 @@ function subscribeChannel() { }) } +function handleReconnected() { + if (!isConfirmed.value) return + echo.leave(`booking.${props.bookingId}`) + channel.value = null + subscribeChannel() +} + onMounted(async () => { await requestBrowserNotificationPermission() await loadHistory() if (isConfirmed.value) { subscribeChannel() + echo.connector.pusher.connection.bind('reconnected', handleReconnected) } }) onUnmounted(() => { + echo.connector.pusher.connection.unbind('reconnected', handleReconnected) 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() { -- 2.52.0 From 6b3a0816f5ca23c0c285b00fa33673abaee28add Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 28 May 2026 17:59:10 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=E8=81=8A=E5=A4=A9=E5=AE=A4=E6=A8=82?= =?UTF-8?q?=E8=A7=80=E6=9B=B4=E6=96=B0=20+=20private=20channel=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 問題:presence channel 在另一方加入的瞬間可能短暫抖動, 導致雙方都收不到 MessageSent event,訊息消失需重整。 三項修正: 1. sendText 樂觀更新:訊息送出後立即顯示暫存訊息, 不依賴 WebSocket event 才出現。 2. MessageSent handler deduplication:自己的訊息收到 event 時,替換暫存訊息而非重複 push。 3. Private channel fallback:receiver 的 private channel 收到 notification.created 時,補拉一次訊息歷史, 確保 presence channel 失效時仍能同步。使用 stopListening 避免干擾 notifications store 的訂閱。 Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/BookingChat.vue | 48 ++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/BookingChat.vue b/frontend/src/components/BookingChat.vue index b6ce904..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() @@ -177,17 +208,32 @@ function handleReconnected() { 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}`) -- 2.52.0