fix: 避免 entrypoint 覆寫 .env 的 DB 憑證 #11
@@ -16,7 +16,9 @@ class BookingPresenceChannel
|
|||||||
$booking->loadMissing('schedule');
|
$booking->loadMissing('schedule');
|
||||||
|
|
||||||
$isMember = $user->role === 'member' && $booking->member_id === $user->id;
|
$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) {
|
if (!$isMember && !$isProvider) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
+1
-1
@@ -70,7 +70,7 @@ services:
|
|||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
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_APP_KEY: ${VITE_REVERB_APP_KEY}
|
||||||
VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost}
|
VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost}
|
||||||
VITE_REVERB_PORT: ${VITE_REVERB_PORT:-8085}
|
VITE_REVERB_PORT: ${VITE_REVERB_PORT:-8085}
|
||||||
|
|||||||
+38
-113
@@ -3,136 +3,61 @@ set -e
|
|||||||
|
|
||||||
echo "=== CFDivePlatform 容器初始化開始 ==="
|
echo "=== CFDivePlatform 容器初始化開始 ==="
|
||||||
|
|
||||||
# 檢查目錄結構
|
# 確保目錄與權限(php-fpm 啟動前必須完成)
|
||||||
if [ ! -d "/var/www/storage" ]; then
|
[ ! -d "/var/www/storage" ] && mkdir -p /var/www/storage
|
||||||
echo "創建 storage 目錄..."
|
[ ! -d "/var/www/bootstrap/cache" ] && mkdir -p /var/www/bootstrap/cache
|
||||||
mkdir -p /var/www/storage
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -d "/var/www/bootstrap/cache" ]; then
|
|
||||||
echo "創建 bootstrap/cache 目錄..."
|
|
||||||
mkdir -p /var/www/bootstrap/cache
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 設置權限
|
|
||||||
echo "設置目錄權限..."
|
|
||||||
chown -R www-data:www-data /var/www
|
chown -R www-data:www-data /var/www
|
||||||
chmod -R 775 /var/www/storage /var/www/bootstrap/cache
|
chmod -R 775 /var/www/storage /var/www/bootstrap/cache
|
||||||
|
|
||||||
# 等待 MySQL 服務啟動
|
# 確保 .env 存在
|
||||||
echo "等待 MySQL 服務啟動..."
|
if [ ! -f .env ]; then
|
||||||
|
cp .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
fi
|
||||||
|
|
||||||
# 使用更穩定的方法檢查 MySQL 連接
|
# 強制 DB_HOST=db(Docker service name,不能用 localhost)
|
||||||
MAX_TRIES=60
|
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);
|
||||||
|
"
|
||||||
|
|
||||||
|
# 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
|
COUNT=0
|
||||||
|
until mysqladmin ping -h"db" -u"${DB_USERNAME:-cfdiveuser}" -p"${DB_PASSWORD}" --silent 2>/dev/null || [ $COUNT -ge 30 ]; do
|
||||||
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)"
|
|
||||||
sleep 2
|
sleep 2
|
||||||
COUNT=$((COUNT+1))
|
COUNT=$((COUNT+1))
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ $COUNT -eq $MAX_TRIES ]; then
|
echo "🗄️ [背景] 執行 migration..."
|
||||||
echo "⚠️ 無法連接到 MySQL,但將繼續啟動服務"
|
php artisan migrate --force || echo "⚠️ migration 失敗"
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_mysql
|
echo "🧹 [背景] 清除 Laravel 緩存..."
|
||||||
|
|
||||||
# 檢查並安裝 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'
|
|
||||||
<?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 "🗄️ 執行數據庫遷移..."
|
|
||||||
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 config:clear || true
|
||||||
php artisan cache:clear || true
|
php artisan cache:clear || true
|
||||||
php artisan route:clear || true
|
php artisan route:clear || true
|
||||||
php artisan view:clear || true
|
php artisan view:clear || true
|
||||||
|
|
||||||
# 生成 Swagger 文檔(如果可能)
|
echo "🔗 [背景] storage:link..."
|
||||||
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
|
php artisan storage:link --force || true
|
||||||
|
|
||||||
# 啟動 cron daemon(Laravel Scheduler)
|
if php -r "echo class_exists('L5Swagger\\L5SwaggerServiceProvider') ? 'yes' : 'no';" 2>/dev/null | grep -q 'yes'; then
|
||||||
echo "⏰ 啟動 Laravel Scheduler cron..."
|
php artisan l5-swagger:generate || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ [背景] 初始化完成"
|
||||||
|
) &
|
||||||
|
|
||||||
|
# 啟動 cron(Laravel Scheduler)
|
||||||
service cron start || cron || true
|
service cron start || cron || true
|
||||||
|
|
||||||
# 執行傳入的命令
|
echo "🚀 啟動 php-fpm..."
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import api from '../api/axios'
|
|||||||
import coachApi from '../api/coachAxios'
|
import coachApi from '../api/coachAxios'
|
||||||
import echo from '../plugins/echo'
|
import echo from '../plugins/echo'
|
||||||
import { useNotificationStore } from '../stores/notifications'
|
import { useNotificationStore } from '../stores/notifications'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useCoachAuthStore } from '../stores/coachAuth'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
bookingId: { type: Number, required: true },
|
bookingId: { type: Number, required: true },
|
||||||
@@ -21,6 +23,12 @@ const otherUserOnline = ref(false)
|
|||||||
const channel = ref(null)
|
const channel = ref(null)
|
||||||
|
|
||||||
const notificationStore = useNotificationStore()
|
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 isConfirmed = computed(() => props.bookingStatus === 'confirmed')
|
||||||
const isCompleted = computed(() => props.bookingStatus === 'completed')
|
const isCompleted = computed(() => props.bookingStatus === 'completed')
|
||||||
@@ -91,6 +99,19 @@ async function sendText() {
|
|||||||
const content = textInput.value.trim()
|
const content = textInput.value.trim()
|
||||||
textInput.value = ''
|
textInput.value = ''
|
||||||
isSending.value = true
|
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 {
|
try {
|
||||||
await axiosInstance.value.post(`/bookings/${props.bookingId}/messages`, {
|
await axiosInstance.value.post(`/bookings/${props.bookingId}/messages`, {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -98,6 +119,7 @@ async function sendText() {
|
|||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
textInput.value = content
|
textInput.value = content
|
||||||
|
messages.value = messages.value.filter(m => m.id !== tempId)
|
||||||
} finally {
|
} finally {
|
||||||
isSending.value = false
|
isSending.value = false
|
||||||
}
|
}
|
||||||
@@ -142,6 +164,16 @@ function subscribeChannel() {
|
|||||||
if (e.user_type === otherType.value) otherUserOnline.value = e.online
|
if (e.user_type === otherType.value) otherUserOnline.value = e.online
|
||||||
})
|
})
|
||||||
.listen('.MessageSent', async (e) => {
|
.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({
|
messages.value.push({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
sender_id: e.sender_id,
|
sender_id: e.sender_id,
|
||||||
@@ -153,7 +185,6 @@ function subscribeChannel() {
|
|||||||
})
|
})
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
if (e.sender_type !== props.currentUserType) {
|
if (e.sender_type !== props.currentUserType) {
|
||||||
// 對方傳來的訊息:推瀏覽器通知、刷新 bell badge
|
|
||||||
showBrowserNotification(e)
|
showBrowserNotification(e)
|
||||||
notificationStore.fetchUnreadCount()
|
notificationStore.fetchUnreadCount()
|
||||||
await markLastRead()
|
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 () => {
|
onMounted(async () => {
|
||||||
await requestBrowserNotificationPermission()
|
await requestBrowserNotificationPermission()
|
||||||
await loadHistory()
|
await loadHistory()
|
||||||
if (isConfirmed.value) {
|
if (isConfirmed.value) {
|
||||||
subscribeChannel()
|
subscribeChannel()
|
||||||
|
echo.connector.pusher.connection.bind('reconnected', handleReconnected)
|
||||||
|
if (currentUserId.value) {
|
||||||
|
echo.private(`App.Models.User.${currentUserId.value}`)
|
||||||
|
.listen('.notification.created', handleNotificationFallback)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
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) {
|
if (channel.value) {
|
||||||
channel.value.whisper('presence', { user_type: props.currentUserType, online: false })
|
channel.value.whisper('presence', { user_type: props.currentUserType, online: false })
|
||||||
echo.leave(`booking.${props.bookingId}`)
|
echo.leave(`booking.${props.bookingId}`)
|
||||||
|
|||||||
@@ -27,10 +27,13 @@ const echo = new Echo({
|
|||||||
// 登入後更新 auth header,讓 presence channel 授權帶正確 token
|
// 登入後更新 auth header,讓 presence channel 授權帶正確 token
|
||||||
export function updateEchoToken() {
|
export function updateEchoToken() {
|
||||||
const token = getAuthToken()
|
const token = getAuthToken()
|
||||||
echo.options.auth.headers.Authorization = `Bearer ${token}`
|
const bearer = `Bearer ${token}`
|
||||||
// 重新連線讓新 token 生效
|
echo.options.auth.headers.Authorization = bearer
|
||||||
echo.disconnect()
|
// 同步更新 Pusher 內部 config(避免 Pusher.js 持有舊的 header copy)
|
||||||
echo.connect()
|
if (echo.connector?.pusher?.config?.auth?.headers) {
|
||||||
|
echo.connector.pusher.config.auth.headers.Authorization = bearer
|
||||||
|
}
|
||||||
|
// 不 disconnect/connect:避免中斷 BookingChat 已訂閱的 presence channel
|
||||||
}
|
}
|
||||||
|
|
||||||
export default echo
|
export default echo
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
localStorage.setItem('user', JSON.stringify(userData))
|
localStorage.setItem('user', JSON.stringify(userData))
|
||||||
const ns = useNotificationStore()
|
const ns = useNotificationStore()
|
||||||
ns.startPolling()
|
ns.startPolling()
|
||||||
ns.startRealtime(userData.id)
|
|
||||||
updateEchoToken()
|
updateEchoToken()
|
||||||
|
ns.startRealtime(userData.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ export const useCoachAuthStore = defineStore('coachAuth', () => {
|
|||||||
localStorage.setItem('coach_user', JSON.stringify(userData))
|
localStorage.setItem('coach_user', JSON.stringify(userData))
|
||||||
const ns = useNotificationStore()
|
const ns = useNotificationStore()
|
||||||
ns.startPolling()
|
ns.startPolling()
|
||||||
ns.startRealtime(userData.id)
|
|
||||||
updateEchoToken()
|
updateEchoToken()
|
||||||
|
ns.startRealtime(userData.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
|
|||||||
Reference in New Issue
Block a user