Files
Gnuboard7/public/install/api/install-worker.php
T
2026-04-01 10:30:52 +09:00

1643 lines
57 KiB
PHP

<?php
/**
* 그누보드7 웹 인스톨러 - SSE 기반 설치 작업 워커
*
* Server-Sent Events(SSE)를 사용하여 실시간으로 설치 진행 상태를 스트리밍합니다.
* 브라우저 연결이 끊어지면 즉시 설치를 중단합니다.
*
* @package G7\Installer
*/
// 필수 파일 include (config.php가 BASE_PATH를 정의함)
require_once __DIR__ . '/../includes/config.php';
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/installer-state.php';
require_once __DIR__ . '/rollback-functions.php';
// SSE는 세션을 사용하지 않음 (세션 잠금 방지)
// state.json에서 모든 정보를 가져옴
// 현재 언어 가져오기 (state.json에서)
$state = getInstallationState();
$lang = $state['g7_locale'] ?? 'ko';
$translations = loadTranslations($lang);
// GET 요청만 허용 (SSE는 GET 기반)
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => lang('sse_method_not_allowed'),
], JSON_UNESCAPED_UNICODE);
exit;
}
// SSE 헤더 설정
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no'); // Nginx 버퍼링 비활성화
// 출력 버퍼 비활성화 (즉시 전송)
@ini_set('output_buffering', 'off');
@ini_set('zlib.output_compression', false);
// Apache 환경인 경우에만 gzip 비활성화
if (function_exists('apache_setenv')) {
@apache_setenv('no-gzip', 1);
}
// 모든 출력 버퍼 레벨 제거
while (ob_get_level() > 0) {
ob_end_clean();
}
// 타임아웃 설정 (10분)
set_time_limit(600);
// 오류를 SSE 이벤트로 전송하기 위해 오류 출력 비활성화
ini_set('display_errors', '0');
error_reporting(E_ALL);
// Worker 시작 로그 (디버깅용)
addLog('=== Install Worker SSE Started ===');
addLog('Client IP: ' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
// escapeEnvValue()와 generateEnvContent()는 functions.php에 정의됨
/**
* SSE 이벤트 전송
*
* @param string $event 이벤트 타입
* @param array $data 데이터
*/
function sendSSEEvent(string $event, array $data): void
{
echo "event: {$event}\n";
echo "data: " . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
flush();
// log 이벤트인 경우 installation.log에도 기록
if ($event === 'log' && isset($data['message'])) {
addLog($data['message']);
}
}
/**
* 롤백 실행 결과를 SSE 로그로 출력합니다.
*
* @param array $rollbackResult rollbackDbMigrate() 등의 반환값
*/
function sendRollbackOutputSSE(array $rollbackResult): void
{
if (!empty($rollbackResult['output']) && is_array($rollbackResult['output'])) {
foreach ($rollbackResult['output'] as $line) {
if (!empty(trim($line))) {
sendSSEEvent('log', ['message' => $line]);
}
}
}
}
/**
* 설치 중단 여부 확인 (SSE 버전)
*
* 브라우저 연결이 끊어졌거나 사용자가 중단했는지 확인합니다.
*/
function checkAbortStatusSSE(): bool
{
// 1. 브라우저 연결 확인
if (connection_aborted()) {
addLog(lang('abort_connection_lost'));
$state = getInstallationState();
// 현재 진행 중인 작업만 롤백 (abort.php와 동일한 로직)
$currentTask = $state['current_task'] ?? null;
if ($currentTask && !in_array($currentTask, $state['completed_tasks'] ?? [])) {
addLog(lang('abort_rollback_start', ['task' => $currentTask]));
$rollbackResult = rollbackTask($currentTask, $state);
// 롤백 상세 로그 출력 (있는 경우)
if (!empty($rollbackResult['output']) && is_array($rollbackResult['output'])) {
$outputStr = implode("\n", $rollbackResult['output']);
addLog($outputStr);
}
if ($rollbackResult['success']) {
addLog(lang('abort_rollback_success', ['message' => $rollbackResult['message']]));
} else {
addLog(lang('abort_rollback_failed', ['message' => $rollbackResult['message']]));
// 롤백 실패 시 안내 (로그만, connection_aborted 상태이므로 SSE는 무의미)
$dbTasks = ['db_migrate', 'db_seed'];
if (in_array($currentTask, $dbTasks)) {
addLog(lang('failed_rollback_manual_cleanup'));
addLog(lang('failed_rollback_manual_cleanup_detail'));
} else {
addLog(lang('failed_rollback_retry'));
addLog(lang('failed_rollback_retry_detail'));
}
}
} else {
addLog(lang('abort_no_rollback_needed'));
}
// 설치 상태를 'aborted'로 변경
$state['installation_status'] = 'aborted';
$state['current_task'] = null;
// completed_tasks는 유지
$state['abort_reason'] = 'Connection aborted unexpectedly';
$state['aborted_at'] = date('Y-m-d H:i:s');
// 롤백 실패 정보 저장 (새로고침 후에도 표시용)
if (isset($rollbackResult) && !$rollbackResult['success']) {
$state['rollback_failure'] = [
'task' => $currentTask,
'message' => $rollbackResult['message'] ?? null,
'message_key' => 'failed_rollback_manual_cleanup',
'detail_key' => 'failed_rollback_manual_cleanup_detail',
];
}
saveInstallationState($state);
return true;
}
// 2. state.json에서 중단 여부 확인
$state = getInstallationState();
if (isset($state['installation_status']) && $state['installation_status'] === 'aborted') {
$currentTask = $state['current_task'] ?? 'unknown';
addLog(lang('abort_by_user', ['task' => $currentTask]));
return true;
}
return false;
}
/**
* 설치 상태에서 PHP 바이너리 경로를 가져옵니다.
*
* @return string PHP 바이너리 경로
*/
function getPhpBinary(): string
{
$state = getInstallationState();
return $state['config']['php_binary'] ?? 'php' ?: 'php';
}
/**
* 설치 상태에서 Composer 실행 명령어를 생성합니다. (실행용 — escapeshellarg 적용)
*
* @return string Composer 실행 명령어
*/
function getComposerCommand(): string
{
$state = getInstallationState();
$composerBinary = $state['config']['composer_binary'] ?? '';
if ($composerBinary) {
// 공백 포함 = 전체 실행 명령어 (예: "/usr/local/php84/bin/php /home/user/g7/composer.phar")
if (str_contains($composerBinary, ' ')) {
return $composerBinary;
}
return str_ends_with($composerBinary, '.phar')
? escapeshellarg(getPhpBinary()) . ' ' . escapeshellarg($composerBinary)
: escapeshellarg($composerBinary);
}
return 'composer';
}
/**
* 수동 실행 안내용 Composer 명령어를 생성합니다. (표시용 — escapeshellarg 미적용)
*
* @return string Composer 표시용 명령어
*/
function getComposerCommandForDisplay(): string
{
$state = getInstallationState();
$composerBinary = $state['config']['composer_binary'] ?? '';
if ($composerBinary) {
// 공백 포함 = 전체 실행 명령어 → 그대로 표시
if (str_contains($composerBinary, ' ')) {
return $composerBinary;
}
return str_ends_with($composerBinary, '.phar')
? getPhpBinary() . ' ' . $composerBinary
: $composerBinary;
}
return 'composer';
}
/**
* Task 1: Composer 확인
*/
function checkComposerSSE(): array
{
if (checkAbortStatusSSE()) {
return ['success' => false, 'aborted' => true];
}
$taskName = lang('task_composer_check');
updateCurrentTask('composer_check');
sendSSEEvent('task_start', [
'task' => 'composer_check',
'name' => $taskName,
]);
// 작업 시작 로그
sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]);
// COMPOSER_HOME 환경변수 설정 (웹 서버 환경에서 필수)
$composerHome = BASE_PATH . '/storage/composer';
if (!is_dir($composerHome)) {
@mkdir($composerHome, 0755, true);
}
putenv('COMPOSER_HOME=' . $composerHome);
putenv('HOME=' . $composerHome);
// composer 명령어 확인
$output = [];
$returnCode = 0;
$composerCmd = getComposerCommand();
exec($composerCmd . ' --version 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$errorMessage = implode("\n", $output);
sendSSEEvent('log', ['message' => lang('log_error_occurred', ['error' => $errorMessage])]);
logInstallationError(lang('error_composer_not_installed'));
return [
'success' => false,
'message' => lang('error_composer_not_installed'), // 로그용
'message_key' => 'error_composer_not_installed', // state.json 저장용
'detail' => $errorMessage,
];
}
// 실제 출력 결과 로그
foreach ($output as $line) {
sendSSEEvent('log', ['message' => $line]);
}
// 작업 완료 로그
sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]);
sendSSEEvent('log', ['message' => lang('log_separator')]);
markTaskCompleted('composer_check');
sendSSEEvent('task_complete', [
'task' => 'composer_check',
'message' => lang('log_composer_check_success'),
]);
return ['success' => true];
}
/**
* Task 2: Composer 의존성 설치
*
* 4가지 케이스를 처리합니다:
* 1. vendor ✅ + lock ✅ → skip (이미 정상 설치됨)
* 2. vendor ✅ + lock ❌ → vendor 삭제 후 install (불완전 상태)
* 3. vendor ❌ + lock ✅ → 일반 install (lock 파일 사용)
* 4. vendor ❌ + lock ❌ → 일반 install (lock 생성됨)
*/
function installComposerDependenciesSSE(): array
{
if (checkAbortStatusSSE()) {
return ['success' => false, 'aborted' => true];
}
$taskName = lang('task_composer_install');
updateCurrentTask('composer_install');
sendSSEEvent('task_start', [
'task' => 'composer_install',
'name' => $taskName,
]);
// 작업 시작 로그
sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]);
$vendorExists = is_dir(BASE_PATH . '/vendor') && file_exists(BASE_PATH . '/vendor/autoload.php');
$lockExists = file_exists(BASE_PATH . '/composer.lock');
// ========================================================================
// 케이스 1: vendor ✅ + lock ✅ → skip (이미 정상 설치됨)
// ========================================================================
if ($vendorExists && $lockExists) {
sendSSEEvent('log', ['message' => lang('log_composer_already_installed')]);
sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]);
sendSSEEvent('log', ['message' => lang('log_separator')]);
markTaskCompleted('composer_install');
sendSSEEvent('task_complete', [
'task' => 'composer_install',
'message' => lang('log_composer_already_installed'),
]);
return ['success' => true];
}
// ========================================================================
// 케이스 2: vendor ✅ + lock ❌ → vendor 삭제 후 install
// ========================================================================
if ($vendorExists && !$lockExists) {
sendSSEEvent('log', ['message' => lang('log_composer_vendor_without_lock')]);
sendSSEEvent('log', ['message' => lang('log_composer_removing_vendor')]);
$deleted = deleteDirectory(BASE_PATH . '/vendor');
if (!$deleted) {
sendSSEEvent('log', ['message' => lang('log_composer_vendor_delete_failed')]);
// 실패해도 install 시도 (덮어쓰기 될 수 있음)
} else {
sendSSEEvent('log', ['message' => lang('log_composer_vendor_deleted')]);
}
}
// ========================================================================
// 케이스 3: vendor ❌ + lock ✅ → 일반 install (lock 파일 사용)
// ========================================================================
if (!$vendorExists && $lockExists) {
sendSSEEvent('log', ['message' => lang('log_composer_installing_from_lock')]);
}
// ========================================================================
// 케이스 4: vendor ❌ + lock ❌ → 일반 install
// ========================================================================
if (!$vendorExists && !$lockExists) {
sendSSEEvent('log', ['message' => lang('log_composer_fresh_install')]);
}
// --no-dev 설치 전 bootstrap cache 삭제 (이전 개발 환경 캐시와 충돌 방지)
$bootstrapCachePath = BASE_PATH . '/bootstrap/cache/packages.php';
if (file_exists($bootstrapCachePath)) {
@unlink($bootstrapCachePath);
sendSSEEvent('log', ['message' => lang('log_composer_cache_cleared')]);
}
// composer install 실행
chdir(BASE_PATH);
// COMPOSER_HOME 환경변수 설정 (웹 서버 환경에서 필수)
$composerHome = BASE_PATH . '/storage/composer';
if (!is_dir($composerHome)) {
@mkdir($composerHome, 0755, true);
}
// 환경변수 배열 구성
$env = [];
// 현재 환경변수 복사 (getenv()로 가져오기)
foreach (['PATH', 'SystemRoot', 'TEMP', 'TMP', 'APPDATA', 'LOCALAPPDATA', 'USERPROFILE'] as $key) {
$value = getenv($key);
if ($value !== false) {
$env[$key] = $value;
}
}
// Composer 관련 환경변수 설정
$env['COMPOSER_HOME'] = $composerHome;
$env['HOME'] = $composerHome;
// Windows에서 추가로 필요한 환경변수
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
if (!isset($env['TEMP']) || !is_dir($env['TEMP']) || !is_writable($env['TEMP'])) {
// TEMP가 없거나 쓰기 불가능하면 storage/temp 사용
$tempDir = BASE_PATH . '/storage/temp';
if (!is_dir($tempDir)) {
@mkdir($tempDir, 0755, true);
}
$env['TEMP'] = $tempDir;
$env['TMP'] = $tempDir;
}
}
$descriptorspec = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$composerCmd = getComposerCommand();
$process = proc_open(
$composerCmd . ' install --no-interaction --no-dev --optimize-autoloader --no-ansi 2>&1',
$descriptorspec,
$pipes,
BASE_PATH,
$env
);
if (!is_resource($process)) {
logInstallationError(lang('error_composer_install_failed'));
return [
'success' => false,
'message' => lang('error_composer_install_failed'), // 로그용
'message_key' => 'error_composer_install_failed', // state.json 저장용
'detail' => lang('error_composer_process_failed'),
];
}
fclose($pipes[0]);
stream_set_blocking($pipes[1], false);
// 실시간 출력 읽기 및 SSE 전송
while (!feof($pipes[1])) {
// 중단 체크
if (checkAbortStatusSSE()) {
proc_terminate($process);
proc_close($process);
return ['success' => false, 'aborted' => true];
}
$line = fgets($pipes[1]);
if ($line !== false) {
$line = trim($line);
if (!empty($line)) {
sendSSEEvent('log', ['message' => $line]);
}
}
usleep(100000); // 0.1초
}
fclose($pipes[1]);
fclose($pipes[2]);
$returnCode = proc_close($process);
if ($returnCode !== 0) {
logInstallationError(lang('error_composer_install_failed'));
return [
'success' => false,
'message' => lang('error_composer_install_failed'), // 로그용
'message_key' => 'error_composer_install_failed', // state.json 저장용
'detail' => lang('error_composer_exit_code', ['code' => $returnCode]),
];
}
// 작업 완료 로그
sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]);
sendSSEEvent('log', ['message' => lang('log_separator')]);
markTaskCompleted('composer_install');
sendSSEEvent('task_complete', [
'task' => 'composer_install',
'message' => lang('log_composer_install_success'),
]);
return ['success' => true];
}
/**
* Task 3: .env 파일 업데이트
*
* 사용자가 미리 생성한 .env 파일에 설정값을 채워 넣습니다.
* .env가 없으면 에러를 반환합니다 (Step 5 UI에서 이미 존재 확인했으므로 비정상 상황).
* .env에 쓰기 권한이 없으면 경고 로그만 남기고 성공 처리합니다.
*/
function updateEnvFileSSE(): array
{
if (checkAbortStatusSSE()) {
return ['success' => false, 'aborted' => true];
}
$taskName = lang('task_env_update');
updateCurrentTask('env_update');
sendSSEEvent('task_start', [
'task' => 'env_update',
'name' => $taskName,
]);
// 작업 시작 로그
sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]);
$envPath = BASE_PATH . '/.env';
// .env 파일 존재 여부 확인
if (!file_exists($envPath)) {
logInstallationError(lang('error_env_not_found'));
return [
'success' => false,
'message' => lang('error_env_not_found'),
'message_key' => 'error_env_not_found',
];
}
// .env 쓰기 가능 여부 확인
if (is_writable($envPath)) {
$content = generateEnvContent();
if ($content !== null) {
file_put_contents($envPath, $content);
@chmod($envPath, 0600);
}
sendSSEEvent('log', ['message' => lang('log_env_update_success')]);
} else {
// .env 읽기 전용 — 경고만 남기고 성공 처리
sendSSEEvent('log', ['message' => lang('log_env_readonly_skip')]);
}
// 작업 완료 로그
sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]);
sendSSEEvent('log', ['message' => lang('log_separator')]);
markTaskCompleted('env_update');
sendSSEEvent('task_complete', [
'task' => 'env_update',
'message' => lang('log_env_update_success'),
]);
return ['success' => true];
}
/**
* Task 4: Application Key 생성
*
* 기존 APP_KEY가 이미 유효한 값이면 건너뜁니다.
*/
function generateApplicationKeySSE(): array
{
// 기존 APP_KEY가 유효하면 건너뛰기
$envPath = BASE_PATH . '/.env';
if (file_exists($envPath)) {
$content = file_get_contents($envPath);
if (preg_match('/^APP_KEY=base64:.{40,}$/m', $content)) {
// 이미 유효한 APP_KEY 존재 → 건너뛰기
$taskName = lang('task_key_generate');
updateCurrentTask('key_generate');
sendSSEEvent('task_start', [
'task' => 'key_generate',
'name' => $taskName,
]);
sendSSEEvent('log', ['message' => lang('log_already_completed', ['task' => $taskName])]);
sendSSEEvent('log', ['message' => lang('log_separator')]);
markTaskCompleted('key_generate');
sendSSEEvent('task_complete', [
'task' => 'key_generate',
'message' => lang('log_key_generate_success'),
]);
return ['success' => true];
}
}
return executeArtisanCommandSSE(
artisanCommand: 'key:generate --force',
taskId: 'key_generate',
taskNameKey: 'task_key_generate',
successMsgKey: 'log_key_generate_success',
errorMsgKey: 'error_key_generate_failed'
);
}
/**
* Task 5: 데이터베이스 마이그레이션
*
* 코어 및 모듈/플러그인 마이그레이션을 실행합니다.
*/
function runDatabaseMigrationSSE(): array
{
// DB 작업은 중단 요청 시에도 완료 후 롤백하도록 별도 함수 사용
return executeDbCommandSSE(
artisanCommand: 'migrate --force',
taskId: 'db_migrate',
taskNameKey: 'task_db_migrate',
successMsgKey: 'log_db_migrate_success',
errorMsgKey: 'error_db_migrate_failed'
);
}
/**
* Task 6: 기본 데이터 시딩
*/
function runDatabaseSeedingSSE(): array
{
// 관리자 계정 정보를 환경변수로 설정 (AdminUserSeeder에서 사용)
$state = getInstallationState();
$config = $state['config'] ?? [];
if (empty($config['admin_email']) || empty($config['admin_password'])) {
return ['success' => false, 'error' => '관리자 이메일과 비밀번호가 설정되지 않았습니다.'];
}
putenv('INSTALLER_ADMIN_NAME=' . ($config['admin_name'] ?? 'Administrator'));
putenv('INSTALLER_ADMIN_EMAIL=' . $config['admin_email']);
putenv('INSTALLER_ADMIN_PASSWORD=' . $config['admin_password']);
putenv('INSTALLER_ADMIN_LANGUAGE=' . ($config['admin_language'] ?? $state['g7_locale'] ?? 'ko'));
// DB 작업은 중단 요청 시에도 완료 후 롤백하도록 별도 함수 사용
return executeDbCommandSSE(
artisanCommand: 'db:seed --force',
taskId: 'db_seed',
taskNameKey: 'task_db_seed',
successMsgKey: 'log_db_seed_success',
errorMsgKey: 'error_db_seed_failed'
);
}
// ============================================================================
// 선택된 확장 기능 로드
// ============================================================================
/**
* state.json에서 선택된 확장 기능 가져오기
*
* @return array 선택된 확장 기능 배열
*/
function getSelectedExtensions(): array
{
$state = getInstallationState();
return $state['selected_extensions'] ?? [
'admin_templates' => [],
'user_templates' => [],
'modules' => [],
'plugins' => [],
];
}
/**
* Artisan 명령어 실행 (공통 함수)
*
* @param string $artisanCommand Artisan 명령어 (예: "template:install sirsoft-admin_basic", "cache:clear")
* @param string $taskId 작업 ID
* @param string $taskNameKey 작업 이름 번역 키
* @param string $successMsgKey 성공 메시지 번역 키
* @param string $errorMsgKey 에러 메시지 번역 키
* @return array
*/
function executeArtisanCommandSSE(
string $artisanCommand,
string $taskId,
string $taskNameKey,
string $successMsgKey,
string $errorMsgKey
): array {
if (checkAbortStatusSSE()) {
return ['success' => false, 'aborted' => true];
}
$taskName = lang($taskNameKey);
updateCurrentTask($taskId);
sendSSEEvent('task_start', [
'task' => $taskId,
'name' => $taskName,
]);
// 작업 시작 로그
sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]);
chdir(BASE_PATH);
$output = [];
$returnCode = 0;
$phpBin = escapeshellarg(getPhpBinary());
$fullCommand = "{$phpBin} -d memory_limit=512M artisan {$artisanCommand} 2>&1";
exec($fullCommand, $output, $returnCode);
// 명령 출력 로그
foreach ($output as $line) {
if (!empty(trim($line))) {
sendSSEEvent('log', ['message' => $line]);
}
}
if ($returnCode !== 0) {
$errorMessage = implode("\n", $output);
logInstallationError(lang($errorMsgKey), new Exception($errorMessage));
return [
'success' => false,
'message' => lang($errorMsgKey), // 로그용
'message_key' => $errorMsgKey, // state.json 저장용
'detail' => $errorMessage,
];
}
// 작업 완료 로그
sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]);
sendSSEEvent('log', ['message' => lang('log_separator')]);
markTaskCompleted($taskId);
sendSSEEvent('task_complete', [
'task' => $taskId,
'message' => lang($successMsgKey),
]);
return ['success' => true];
}
/**
* DB 작업용 Artisan 명령어 실행 (중단 요청 시에도 완료 후 롤백)
*
* 마이그레이션/시딩 중간에 중단하면 DB 상태가 꼬이므로,
* 작업이 완료될 때까지 기다린 후 롤백 처리합니다.
*
* @param string $artisanCommand Artisan 명령어
* @param string $taskId 작업 ID
* @param string $taskNameKey 작업 이름 번역 키
* @param string $successMsgKey 성공 메시지 번역 키
* @param string $errorMsgKey 에러 메시지 번역 키
* @return array
*/
function executeDbCommandSSE(
string $artisanCommand,
string $taskId,
string $taskNameKey,
string $successMsgKey,
string $errorMsgKey
): array {
// 작업 시작 전 중단 상태 확인 (아직 시작 안했으면 중단)
$state = getInstallationState();
$wasAborted = isset($state['installation_status']) && $state['installation_status'] === 'aborted';
if ($wasAborted) {
addLog(lang('db_task_abort_detected_before_start'));
return ['success' => false, 'aborted' => true];
}
$taskName = lang($taskNameKey);
updateCurrentTask($taskId);
sendSSEEvent('task_start', [
'task' => $taskId,
'name' => $taskName,
]);
// 작업 시작 로그
sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]);
chdir(BASE_PATH);
$output = [];
$returnCode = 0;
$phpBin = escapeshellarg(getPhpBinary());
$fullCommand = "{$phpBin} -d memory_limit=512M artisan {$artisanCommand} 2>&1";
// DB 명령어 실행 (중단 체크 없이 완료까지 실행)
exec($fullCommand, $output, $returnCode);
// 명령 출력 로그
foreach ($output as $line) {
if (!empty(trim($line))) {
sendSSEEvent('log', ['message' => $line]);
}
}
// 작업 실패 시
if ($returnCode !== 0) {
$errorMessage = implode("\n", $output);
logInstallationError(lang($errorMsgKey), new Exception($errorMessage));
// 마이그레이션/시딩 실패 시 롤백 시도
addLog(lang('db_task_failed_rollback_start', ['task' => $taskId]));
sendSSEEvent('log', ['message' => lang('failed_rollback_start', ['task' => $taskName])]);
$rollbackResult = rollbackDbMigrate();
$rollbackFailure = null;
sendRollbackOutputSSE($rollbackResult);
if ($rollbackResult['success']) {
sendSSEEvent('log', ['message' => lang('failed_rollback_success', ['message' => $rollbackResult['message']])]);
// DB 롤백 성공 시 completed_tasks에서 DB 작업 제거 (재시도 시 db_migrate부터 실행)
removeTaskCompleted('db_migrate');
removeTaskCompleted('db_seed');
sendSSEEvent('log', ['message' => lang('failed_rollback_db_restart')]);
} else {
sendSSEEvent('log', ['message' => lang('failed_rollback_failed', ['message' => $rollbackResult['message']])]);
// 롤백 실패 정보 (state 저장 및 SSE 이벤트용)
$rollbackFailure = [
'message_key' => 'failed_rollback_manual_cleanup',
'detail_key' => 'failed_rollback_manual_cleanup_detail',
];
sendSSEEvent('rollback_failed', [
'message' => lang('failed_rollback_manual_cleanup'),
'detail' => lang('failed_rollback_manual_cleanup_detail'),
]);
}
return [
'success' => false,
'message' => lang($errorMsgKey),
'message_key' => $errorMsgKey,
'detail' => $errorMessage,
'rollback_done' => true,
'rollback_failure' => $rollbackFailure,
];
}
// 작업 완료
markTaskCompleted($taskId);
// 작업 완료 후 중단 상태 확인 (완료 후에 롤백 처리)
$state = getInstallationState();
$isAborted = isset($state['installation_status']) && $state['installation_status'] === 'aborted';
$connectionLost = connection_aborted();
if ($isAborted || $connectionLost) {
$reason = $connectionLost ? lang('db_task_abort_reason_connection') : lang('db_task_abort_reason_user');
addLog(lang('db_task_completed_abort_detected', ['task' => $taskName, 'reason' => $reason]));
sendSSEEvent('log', ['message' => lang('db_task_completed_rollback_start', ['task' => $taskName])]);
// DB 롤백 실행
$rollbackResult = rollbackDbMigrate();
sendRollbackOutputSSE($rollbackResult);
if ($rollbackResult['success']) {
sendSSEEvent('log', ['message' => lang('abort_rollback_success', ['message' => $rollbackResult['message']])]);
// DB 롤백 성공 시 completed_tasks에서 DB 작업 제거 (재시도 시 db_migrate부터 실행)
removeTaskCompleted('db_migrate');
removeTaskCompleted('db_seed');
sendSSEEvent('log', ['message' => lang('failed_rollback_db_restart')]);
} else {
sendSSEEvent('log', ['message' => lang('abort_rollback_failed', ['message' => $rollbackResult['message']])]);
sendSSEEvent('log', ['message' => lang('failed_rollback_manual_cleanup')]);
}
return ['success' => false, 'aborted' => true];
}
// 정상 완료
sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]);
sendSSEEvent('log', ['message' => lang('log_separator')]);
sendSSEEvent('task_complete', [
'task' => $taskId,
'message' => lang($successMsgKey),
]);
return ['success' => true];
}
/**
* 확장 명령어 실행 (공통 함수) - target 지원
*
* @param string $artisanCommand Artisan 명령어
* @param string $taskId 작업 ID
* @param string|null $target 대상 확장 식별자
* @param string $taskNameKey 작업 이름 번역 키
* @param string $successMsgKey 성공 메시지 번역 키
* @param string $errorMsgKey 에러 메시지 번역 키
* @return array
*/
function executeExtensionCommandSSE(
string $artisanCommand,
string $taskId,
?string $target,
string $taskNameKey,
string $successMsgKey,
string $errorMsgKey
): array {
if (checkAbortStatusSSE()) {
return ['success' => false, 'aborted' => true];
}
$taskName = lang($taskNameKey);
// 확장 이름 가져오기 (저장된 extension_names에서)
$targetDisplayName = $target;
if ($target) {
$state = getInstallationState();
$extensionNames = $state['extension_names'] ?? [];
$lang = $state['g7_locale'] ?? 'ko';
if (isset($extensionNames[$target])) {
$name = $extensionNames[$target];
// 다국어 이름 처리
if (is_array($name)) {
$targetDisplayName = $name[$lang] ?? $name['ko'] ?? $name['en'] ?? $target;
} else {
$targetDisplayName = $name;
}
}
}
$displayName = $target ? "{$targetDisplayName} {$taskName} ({$target})" : $taskName;
updateCurrentTask($taskId);
sendSSEEvent('task_start', [
'task' => $taskId,
'target' => $target,
'name' => $displayName,
]);
// 작업 시작 로그
sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $displayName])]);
chdir(BASE_PATH);
$output = [];
$returnCode = 0;
$phpBin = escapeshellarg(getPhpBinary());
$fullCommand = "{$phpBin} -d memory_limit=512M artisan {$artisanCommand} 2>&1";
exec($fullCommand, $output, $returnCode);
// 명령 출력 로그
foreach ($output as $line) {
if (!empty(trim($line))) {
sendSSEEvent('log', ['message' => $line]);
}
}
// "이미 설치됨/활성화됨" 패턴 감지 (건너뛰기 처리)
$outputText = implode("\n", $output);
$alreadyExistsPatterns = [
'already installed',
'already active',
'이미 설치',
'이미 활성화',
];
$isAlreadyExists = false;
foreach ($alreadyExistsPatterns as $pattern) {
if (stripos($outputText, $pattern) !== false) {
$isAlreadyExists = true;
break;
}
}
if ($returnCode !== 0 && !$isAlreadyExists) {
$errorMessage = $outputText;
logInstallationError(lang($errorMsgKey), new Exception($errorMessage));
return [
'success' => false,
'message' => lang($errorMsgKey), // 로그용
'message_key' => $errorMsgKey, // state.json 저장용
'detail' => $errorMessage,
'target' => $target,
];
}
// 이미 존재하는 경우 건너뛰기 로그, 그렇지 않으면 완료 로그
if ($isAlreadyExists) {
sendSSEEvent('log', ['message' => lang('log_task_skipped', ['task' => $displayName])]);
} else {
sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $displayName])]);
}
sendSSEEvent('log', ['message' => lang('log_separator')]);
// completed_tasks에 target 정보 포함하여 저장
$completedTaskKey = $target ? "{$taskId}:{$target}" : $taskId;
markTaskCompleted($completedTaskKey);
sendSSEEvent('task_complete', [
'task' => $taskId,
'target' => $target,
'message' => lang($successMsgKey),
]);
return ['success' => true];
}
// ============================================================================
// 관리자 템플릿 설치/활성화
// ============================================================================
/**
* 관리자 템플릿 설치
*
* @param string $templateId 템플릿 식별자
* @return array
*/
function installAdminTemplateSSE(string $templateId): array
{
return executeExtensionCommandSSE(
artisanCommand: "template:install {$templateId}",
taskId: 'template_install',
target: $templateId,
taskNameKey: 'task_template_install',
successMsgKey: 'log_template_install_success',
errorMsgKey: 'error_template_install_failed'
);
}
/**
* 관리자 템플릿 활성화
*
* @param string $templateId 템플릿 식별자
* @return array
*/
function activateAdminTemplateSSE(string $templateId): array
{
return executeExtensionCommandSSE(
artisanCommand: "template:activate {$templateId}",
taskId: 'template_activate',
target: $templateId,
taskNameKey: 'task_template_activate',
successMsgKey: 'log_template_activate_success',
errorMsgKey: 'error_template_activate_failed'
);
}
// ============================================================================
// 모듈 설치/활성화
// ============================================================================
/**
* 모듈 설치
*
* @param string $moduleId 모듈 식별자
* @return array
*/
function installModuleSSE(string $moduleId): array
{
return executeExtensionCommandSSE(
artisanCommand: "module:install {$moduleId}",
taskId: 'module_install',
target: $moduleId,
taskNameKey: 'task_module_install',
successMsgKey: 'log_module_install_success',
errorMsgKey: 'error_module_install_failed'
);
}
/**
* 모듈 활성화
*
* @param string $moduleId 모듈 식별자
* @return array
*/
function activateModuleSSE(string $moduleId): array
{
return executeExtensionCommandSSE(
artisanCommand: "module:activate {$moduleId}",
taskId: 'module_activate',
target: $moduleId,
taskNameKey: 'task_module_activate',
successMsgKey: 'log_module_activate_success',
errorMsgKey: 'error_module_activate_failed'
);
}
// ============================================================================
// 플러그인 설치/활성화
// ============================================================================
/**
* 플러그인 설치
*
* @param string $pluginId 플러그인 식별자
* @return array
*/
function installPluginSSE(string $pluginId): array
{
return executeExtensionCommandSSE(
artisanCommand: "plugin:install {$pluginId}",
taskId: 'plugin_install',
target: $pluginId,
taskNameKey: 'task_plugin_install',
successMsgKey: 'log_plugin_install_success',
errorMsgKey: 'error_plugin_install_failed'
);
}
/**
* 플러그인 활성화
*
* @param string $pluginId 플러그인 식별자
* @return array
*/
function activatePluginSSE(string $pluginId): array
{
return executeExtensionCommandSSE(
artisanCommand: "plugin:activate {$pluginId}",
taskId: 'plugin_activate',
target: $pluginId,
taskNameKey: 'task_plugin_activate',
successMsgKey: 'log_plugin_activate_success',
errorMsgKey: 'error_plugin_activate_failed'
);
}
// ============================================================================
// 사용자 템플릿 설치/활성화
// ============================================================================
/**
* 사용자 템플릿 설치
*
* @param string $templateId 템플릿 식별자
* @return array
*/
function installUserTemplateSSE(string $templateId): array
{
return executeExtensionCommandSSE(
artisanCommand: "template:install {$templateId}",
taskId: 'user_template_install',
target: $templateId,
taskNameKey: 'task_user_template_install',
successMsgKey: 'log_user_template_install_success',
errorMsgKey: 'error_user_template_install_failed'
);
}
/**
* 사용자 템플릿 활성화
*
* @param string $templateId 템플릿 식별자
* @return array
*/
function activateUserTemplateSSE(string $templateId): array
{
return executeExtensionCommandSSE(
artisanCommand: "template:activate {$templateId}",
taskId: 'user_template_activate',
target: $templateId,
taskNameKey: 'task_user_template_activate',
successMsgKey: 'log_user_template_activate_success',
errorMsgKey: 'error_user_template_activate_failed'
);
}
/**
* Task 9: 캐시 클리어
*/
function clearCacheSSE(): array
{
return executeArtisanCommandSSE(
artisanCommand: 'optimize:clear',
taskId: 'cache_clear',
taskNameKey: 'task_cache_clear',
successMsgKey: 'log_cache_clear_success',
errorMsgKey: 'error_cache_clear_failed'
);
}
/**
* Task 10: 설정 JSON 파일 생성
*
* config/settings/defaults.json 파일의 기본값을 기반으로
* 인스톨러 입력값을 오버라이드하여 설정 파일을 생성합니다.
*/
function createSettingsJsonSSE(): array
{
if (checkAbortStatusSSE()) {
return ['success' => false, 'aborted' => true];
}
$taskName = lang('task_create_settings_json');
updateCurrentTask('create_settings_json');
sendSSEEvent('task_start', [
'task' => 'create_settings_json',
'name' => $taskName,
]);
// 작업 시작 로그
sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]);
sendSSEEvent('log', ['message' => lang('log_creating_settings')]);
try {
$settingsDir = BASE_PATH . '/storage/app/settings';
// 디렉토리 생성
if (!is_dir($settingsDir)) {
mkdir($settingsDir, 0755, true);
}
// config/settings/defaults.json에서 기본값 로드
$defaultsFile = BASE_PATH . '/config/settings/defaults.json';
if (!file_exists($defaultsFile)) {
throw new Exception('defaults.json file not found: ' . $defaultsFile);
}
$defaultsContent = file_get_contents($defaultsFile);
$defaultsData = json_decode($defaultsContent, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception('defaults.json JSON parsing failed: ' . json_last_error_msg());
}
$defaults = $defaultsData['defaults'] ?? [];
$categories = $defaultsData['_meta']['categories'] ?? array_keys($defaults);
if (empty($defaults)) {
throw new Exception('defaults.json does not contain defaults section');
}
// state.json에서 인스톨러 입력값 가져오기
$state = getInstallationState();
$config = $state['config'] ?? [];
// 인스톨러 입력값으로 기본값 오버라이드
if (!empty($config['app_name'])) {
$defaults['general']['site_name'] = $config['app_name'];
$defaults['mail']['from_name'] = $config['app_name'];
}
if (!empty($config['app_url'])) {
$defaults['general']['site_url'] = $config['app_url'];
}
if (!empty($config['admin_email'])) {
$defaults['general']['admin_email'] = $config['admin_email'];
$defaults['mail']['from_address'] = $config['admin_email'];
}
// 코어 업데이트 설정 오버라이드
if (!empty($config['core_update_github_url'])) {
$defaults['core_update']['github_url'] = $config['core_update_github_url'];
}
if (!empty($config['core_update_github_token'])) {
$defaults['core_update']['github_token'] = $config['core_update_github_token'];
}
// 현재 인스톨러 언어 설정 적용
$defaults['general']['language'] = getCurrentLanguage();
// 각 카테고리별 JSON 파일 생성
foreach ($categories as $category) {
if (!isset($defaults[$category])) {
sendSSEEvent('log', ['message' => " - {$category}.json skipped (no defaults)"]);
continue;
}
$settings = $defaults[$category];
$data = [
'_meta' => [
'version' => '1.0.0',
'updated_at' => date('c'),
],
];
$data = array_merge($data, $settings);
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
$filePath = $settingsDir . '/' . $category . '.json';
file_put_contents($filePath, $json, LOCK_EX);
sendSSEEvent('log', ['message' => " - {$category}.json created"]);
}
// 작업 완료 로그
sendSSEEvent('log', ['message' => lang('log_settings_json_created')]);
sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]);
sendSSEEvent('log', ['message' => lang('log_separator')]);
markTaskCompleted('create_settings_json');
sendSSEEvent('task_complete', [
'task' => 'create_settings_json',
'message' => lang('log_settings_json_created'),
]);
return ['success' => true];
} catch (Exception $e) {
logInstallationError(lang('error_settings_json_failed'), $e);
return [
'success' => false,
'message' => lang('error_settings_json_failed'),
'message_key' => 'error_settings_json_failed',
'detail' => $e->getMessage(),
];
}
}
/**
* Task 11: 설치 완료 플래그 설정
*/
function setInstallationCompleteSSE(): array
{
if (checkAbortStatusSSE()) {
return ['success' => false, 'aborted' => true];
}
$taskName = lang('task_complete_flag');
updateCurrentTask('complete_flag');
sendSSEEvent('task_start', [
'task' => 'complete_flag',
'name' => $taskName,
]);
// 작업 시작 로그
sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]);
try {
// .env 파일에 플래그 추가 (graceful — 쓰기 불가 시 건너뜀)
$envPath = BASE_PATH . '/.env';
if (file_exists($envPath) && is_writable($envPath)) {
$envContent = file_get_contents($envPath);
$envContent .= "\n\n# Installation Status\n";
$envContent .= "INSTALLER_COMPLETED=true\n";
file_put_contents($envPath, $envContent);
sendSSEEvent('log', ['message' => lang('log_env_flag_added')]);
} else {
sendSSEEvent('log', ['message' => lang('log_env_flag_skipped')]);
}
// g7_installed 파일 생성
$installedFlagPath = BASE_PATH . '/storage/app/g7_installed';
$installedFlagDir = dirname($installedFlagPath);
if (!is_dir($installedFlagDir)) {
@mkdir($installedFlagDir, 0775, true);
}
file_put_contents($installedFlagPath, date('Y-m-d H:i:s'));
@chmod($installedFlagPath, 0644);
sendSSEEvent('log', ['message' => lang('log_installed_flag_created')]);
// 설치 상태 업데이트
$state = getInstallationState();
$state['current_step'] = 5;
$state['step_status']['5'] = 'completed';
$state['installation_status'] = 'completed';
$state['installation_completed_at'] = date('Y-m-d\TH:i:s\Z');
saveInstallationState($state);
sendSSEEvent('log', ['message' => lang('log_state_updated')]);
// 작업 완료 로그
sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]);
sendSSEEvent('log', ['message' => lang('log_separator')]);
markTaskCompleted('complete_flag');
sendSSEEvent('task_complete', [
'task' => 'complete_flag',
'message' => lang('log_installation_completed'),
]);
// ============================================================
// ⚠️ INSTALLER 상태 파일 자동 삭제
// ⚠️ config.php의 DELETE_INSTALLER_AFTER_COMPLETE 설정값 사용
// ⚠️ 삭제 비활성화: config.php에서 false로 변경
// ============================================================
if (DELETE_INSTALLER_AFTER_COMPLETE) {
$stateFilePath = BASE_PATH . '/storage/installer-state.json';
if (file_exists($stateFilePath)) {
sendSSEEvent('log', ['message' => lang('log_removing_state_file')]);
if (@unlink($stateFilePath)) {
sendSSEEvent('log', ['message' => lang('log_state_file_removed')]);
addLog(lang('log_state_file_removed') . ': ' . $stateFilePath);
} else {
sendSSEEvent('log', ['message' => lang('log_state_file_remove_failed')]);
addLog(lang('log_state_file_remove_failed') . ': ' . $stateFilePath);
}
}
}
return ['success' => true];
} catch (Exception $e) {
logInstallationError(lang('error_complete_flag_failed'), $e);
return [
'success' => false,
'message' => lang('error_complete_flag_failed'), // 로그용
'message_key' => 'error_complete_flag_failed', // state.json 저장용
'detail' => $e->getMessage(),
];
}
}
// ============================================================================
// 메인 실행 로직
// ============================================================================
// 연결 성공 이벤트 즉시 전송
echo "event: connected\n";
echo "data: " . json_encode(['message' => lang('sse_connection_established')], JSON_UNESCAPED_UNICODE) . "\n\n";
if (ob_get_level()) ob_flush();
flush();
try {
$state = getInstallationState();
$completedTasks = $state['completed_tasks'] ?? [];
$selectedExtensions = getSelectedExtensions();
// ========================================================================
// 동적 작업 목록 생성
// ========================================================================
$tasks = [];
// 1. 환경 설정 작업 (고정)
$tasks[] = ['id' => 'composer_check', 'function' => 'checkComposerSSE'];
$tasks[] = ['id' => 'composer_install', 'function' => 'installComposerDependenciesSSE'];
$tasks[] = ['id' => 'env_update', 'function' => 'updateEnvFileSSE'];
$tasks[] = ['id' => 'key_generate', 'function' => 'generateApplicationKeySSE'];
// 2. 데이터베이스 작업 (고정)
$tasks[] = ['id' => 'db_migrate', 'function' => 'runDatabaseMigrationSSE'];
$tasks[] = ['id' => 'db_seed', 'function' => 'runDatabaseSeedingSSE'];
// 3. 관리자 템플릿 설치/활성화 (선택된 항목)
foreach ($selectedExtensions['admin_templates'] ?? [] as $templateId) {
$tasks[] = ['id' => 'template_install', 'target' => $templateId, 'function' => 'installAdminTemplateSSE', 'args' => [$templateId]];
$tasks[] = ['id' => 'template_activate', 'target' => $templateId, 'function' => 'activateAdminTemplateSSE', 'args' => [$templateId]];
}
// 4. 모듈 설치/활성화 (선택된 항목)
foreach ($selectedExtensions['modules'] ?? [] as $moduleId) {
$tasks[] = ['id' => 'module_install', 'target' => $moduleId, 'function' => 'installModuleSSE', 'args' => [$moduleId]];
$tasks[] = ['id' => 'module_activate', 'target' => $moduleId, 'function' => 'activateModuleSSE', 'args' => [$moduleId]];
}
// 5. 플러그인 설치/활성화 (선택된 항목)
foreach ($selectedExtensions['plugins'] ?? [] as $pluginId) {
$tasks[] = ['id' => 'plugin_install', 'target' => $pluginId, 'function' => 'installPluginSSE', 'args' => [$pluginId]];
$tasks[] = ['id' => 'plugin_activate', 'target' => $pluginId, 'function' => 'activatePluginSSE', 'args' => [$pluginId]];
}
// 6. 사용자 템플릿 설치/활성화 (선택된 항목)
foreach ($selectedExtensions['user_templates'] ?? [] as $templateId) {
$tasks[] = ['id' => 'user_template_install', 'target' => $templateId, 'function' => 'installUserTemplateSSE', 'args' => [$templateId]];
$tasks[] = ['id' => 'user_template_activate', 'target' => $templateId, 'function' => 'activateUserTemplateSSE', 'args' => [$templateId]];
}
// 7. 마무리 작업 (고정)
$tasks[] = ['id' => 'create_settings_json', 'function' => 'createSettingsJsonSSE'];
$tasks[] = ['id' => 'cache_clear', 'function' => 'clearCacheSSE'];
$tasks[] = ['id' => 'complete_flag', 'function' => 'setInstallationCompleteSSE'];
// ========================================================================
// 작업 실행
// ========================================================================
foreach ($tasks as $task) {
// 작업 시작 전 abort 체크 (루프 시작 시 즉시 확인)
if (checkAbortStatusSSE()) {
sendSSEEvent('aborted', [
'message' => lang('abort_installation_stopped'),
'task' => $task['id'],
'target' => $task['target'] ?? null,
]);
addLog(lang('abort_installation_stopped'));
exit;
}
$taskId = $task['id'];
$target = $task['target'] ?? null;
$functionName = $task['function'];
$args = $task['args'] ?? [];
// completed_tasks 키 생성 (target 포함)
$completedKey = $target ? "{$taskId}:{$target}" : $taskId;
// 이미 완료된 작업은 건너뛰기 (하지만 UI 업데이트는 필요)
if (in_array($completedKey, $completedTasks)) {
$taskNameKey = "task_{$taskId}";
$taskName = lang($taskNameKey);
$displayName = $target ? "{$taskName} ({$target})" : $taskName;
sendSSEEvent('task_complete', [
'task' => $taskId,
'target' => $target,
'message' => lang('log_already_completed', ['task' => $displayName]),
]);
continue;
}
// 작업 실행 (인자가 있으면 전달)
$result = empty($args) ? $functionName() : $functionName(...$args);
// 중단된 경우
if (isset($result['aborted']) && $result['aborted']) {
sendSSEEvent('aborted', [
'message' => lang('abort_installation_stopped'),
'task' => $taskId,
'target' => $target,
]);
addLog(lang('abort_installation_stopped'));
exit;
}
// 실패 시 즉시 중단
if (!$result['success']) {
$state = getInstallationState();
$taskNameKey = "task_{$taskId}";
$taskName = lang($taskNameKey);
$displayName = $target ? "{$taskName} ({$target})" : $taskName;
// 실패한 작업 롤백 (executeDbCommandSSE 등에서 이미 롤백한 경우 건너뛰기)
if (empty($result['rollback_done'])) {
sendSSEEvent('log', ['message' => lang('failed_rollback_start', ['task' => $displayName])]);
$rollbackResult = rollbackTask($taskId, $state);
sendRollbackOutputSSE($rollbackResult);
if ($rollbackResult['success']) {
sendSSEEvent('log', ['message' => lang('failed_rollback_success', ['message' => $rollbackResult['message']])]);
} else {
sendSSEEvent('log', ['message' => lang('failed_rollback_failed', ['message' => $rollbackResult['message']])]);
// DB 관련 작업인 경우에만 DB 수동 정리 안내
$dbTasks = ['db_migrate', 'db_seed'];
if (in_array($taskId, $dbTasks)) {
sendSSEEvent('log', ['message' => lang('failed_rollback_manual_cleanup')]);
sendSSEEvent('log', ['message' => lang('failed_rollback_manual_cleanup_detail')]);
$rollbackFailure = [
'message_key' => 'failed_rollback_manual_cleanup',
'detail_key' => 'failed_rollback_manual_cleanup_detail',
];
sendSSEEvent('rollback_failed', [
'message' => lang('failed_rollback_manual_cleanup'),
'detail' => lang('failed_rollback_manual_cleanup_detail')
]);
} else {
// DB 무관 작업 실패 시 재시도 안내
sendSSEEvent('log', ['message' => lang('failed_rollback_retry')]);
sendSSEEvent('log', ['message' => lang('failed_rollback_retry_detail')]);
$rollbackFailure = [
'message_key' => 'failed_rollback_retry',
'detail_key' => 'failed_rollback_retry_detail',
];
sendSSEEvent('rollback_failed', [
'message' => lang('failed_rollback_retry'),
'detail' => lang('failed_rollback_retry_detail')
]);
}
}
}
// 실패 상태 설정
$state['installation_status'] = 'failed';
// 실패 정보 상세 저장 (새로고침 후에도 표시용)
$state['failed_task'] = $completedKey; // target 포함된 키 저장
$state['failed_task_target'] = $target; // target 별도 저장
$state['error_message_key'] = $result['message_key'] ?? null; // 번역 키 저장
$state['error_detail'] = $result['detail'] ?? null;
// 롤백 실패 정보 저장 (새로고침 후에도 표시용)
// rollback_done 경로: 반환값의 rollback_failure 사용, 메인 루프 경로: $rollbackFailure 변수 사용
if (!empty($result['rollback_done'])) {
$state['rollback_failure'] = $result['rollback_failure'] ?? null;
} else {
$state['rollback_failure'] = $rollbackFailure ?? null;
}
// 수동 명령어 생성
$manualCommands = getManualCommands($taskId, $target);
$state['manual_commands'] = $manualCommands;
saveInstallationState($state);
addLog(lang('log_installation_task_failed', [
'task' => $displayName,
'message' => $result['message'] ?? lang('log_installation_failed')
]));
// 수동 명령어 로그 출력
if (!empty($manualCommands)) {
sendSSEEvent('log', ['message' => lang('log_separator')]);
sendSSEEvent('log', ['message' => lang('manual_commands_guide')]);
foreach ($manualCommands as $cmd) {
sendSSEEvent('log', ['message' => " $ {$cmd}"]);
}
sendSSEEvent('log', ['message' => lang('log_separator')]);
}
sendSSEEvent('error', [
'message' => $result['message'] ?? lang('log_installation_failed'),
'message_key' => $result['message_key'] ?? 'log_installation_failed',
'error' => $result['detail'] ?? null,
'task' => $taskId,
'target' => $target,
'manual_commands' => $manualCommands,
]);
exit;
}
}
// 모든 작업 성공
$_SESSION['installer_current_step'] = 5; // 세션을 Step 5로 동기화
sendSSEEvent('completed', [
'message' => lang('log_installation_completed'),
'redirect' => '/install/',
]);
addLog(lang('log_all_tasks_completed'));
} catch (Exception $e) {
logInstallationError(lang('error_worker_exception'), $e);
$state = getInstallationState();
$state['installation_status'] = 'failed';
// 실패 정보 상세 저장 (새로고침 후에도 표시용)
$state['failed_task'] = $state['current_task'] ?? null;
// failed_task_name은 저장하지 않음 (프론트엔드에서 task ID로 번역)
$state['error_message_key'] = 'error_unexpected_exception'; // 예외 발생 시 번역 키
$state['error_detail'] = $e->getMessage();
saveInstallationState($state);
addLog(lang('log_installation_exception', ['error' => $e->getMessage()]));
sendSSEEvent('error', [
'message' => lang('log_installation_failed'),
'message_key' => 'error_unexpected_exception',
'error' => $e->getMessage(),
]);
}