現時点の世代シフトを行う * フルバックアップを行う(ソース + DB) * バッチ処理ログを作成する(SHJ-8呼び出し) */ class ShjTwoService { /** * ShjEightService インスタンス * * @var ShjEightService */ protected $shjEightService; /** * コンストラクタ * * @param ShjEightService $shjEightService */ public function __construct(ShjEightService $shjEightService) { $this->shjEightService = $shjEightService; } /** * SHJ-2 メイン処理実行 * * @return array 処理結果 */ public function execute(): array { $status = 'error'; $statusComment = ''; try { Log::info('SHJ-2 データバックアップ処理開始'); $backupRoot = config('shj2.backup_root'); $dbName = config('database.connections.mysql.database'); $generations = config('shj2.generations', 5); // 世代シフト $this->shiftGenerations($backupRoot, $dbName, $generations); // フルバックアップ $backupResult = $this->executeFullBackup($backupRoot, $dbName); // [JOB2-STEP1] 結果設定 $status = $backupResult['status']; $statusComment = $backupResult['status_comment']; Log::info('SHJ-2 データバックアップ処理完了', [ 'status' => $status, 'status_comment' => $statusComment ]); } catch (\Exception $e) { $status = 'error'; $statusComment = $e->getMessage(); Log::error('SHJ-2 データバックアップ処理エラー', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); } // バッチ処理ログ作成(成功・失敗に関わらず実行) $this->createBatchLog($status, $statusComment); return [ 'success' => $status === 'success', 'status' => $status, 'status_comment' => $statusComment ]; } /** * 世代シフト処理 * * ・5世代前 削除 * ・4世代前 5世代前へ移動 * ・3世代前 4世代前へ移動 * ・2世代前 3世代前へ移動 * ・1世代前(直下)→ 2世代前へ移動 * * @param string $backupRoot バックアップルートディレクトリ * @param string $dbName データベース名 * @param int $generations 世代数 * @return void */ private function shiftGenerations(string $backupRoot, string $dbName, int $generations): void { Log::info('JOB1 世代シフト開始', [ 'backup_root' => $backupRoot, 'db_name' => $dbName, 'generations' => $generations ]); // 最古世代(gen5)のディレクトリパスを取得して削除 $oldestDir = $backupRoot . '/' . $dbName . '_' . $generations; if (is_dir($oldestDir)) { $this->deleteDirectory($oldestDir); Log::info("世代シフト: {$generations}世代前を削除", ['path' => $oldestDir]); } // gen4→gen5, gen3→gen4, gen2→gen3 のシフト for ($i = $generations - 1; $i >= 2; $i--) { $fromDir = $backupRoot . '/' . $dbName . '_' . $i; $toDir = $backupRoot . '/' . $dbName . '_' . ($i + 1); if (is_dir($fromDir)) { rename($fromDir, $toDir); Log::info("世代シフト: {$i}世代前 → " . ($i + 1) . "世代前", [ 'from' => $fromDir, 'to' => $toDir ]); } } // gen1(直下)→ gen2 へ移動 $gen1Dir = $backupRoot . '/' . $dbName; $gen2Dir = $backupRoot . '/' . $dbName . '_2'; if (is_dir($gen1Dir)) { rename($gen1Dir, $gen2Dir); Log::info('世代シフト: 1世代前 → 2世代前', [ 'from' => $gen1Dir, 'to' => $gen2Dir ]); } Log::info('JOB1 世代シフト完了'); } /** * フルバックアップ実行 * * 保存内容: {vendor以外のソース + DB}_YYYYMMDD.tar.gz * * @param string $backupRoot バックアップルートディレクトリ * @param string $dbName データベース名 * @return array ['status' => string, 'status_comment' => string] */ private function executeFullBackup(string $backupRoot, string $dbName): array { Log::info('JOB2 フルバックアップ開始'); $today = Carbon::now()->format('Ymd'); $backupDir = $backupRoot . '/' . $dbName; $tarFileName = $dbName . '_' . $today . '.tar'; $gzFileName = $tarFileName . '.gz'; $tarFilePath = $backupDir . '/' . $tarFileName; $gzFilePath = $backupDir . '/' . $gzFileName; // バックアップディレクトリ作成 if (!is_dir($backupDir)) { mkdir($backupDir, 0755, true); } // 一時ディレクトリ(tar作成用のステージング領域) $tempDir = $backupDir . '/temp_' . $today; if (!is_dir($tempDir)) { mkdir($tempDir, 0755, true); } try { // 1. mysqldump実行 $sqlFileName = $dbName . '.sql'; $sqlFilePath = $tempDir . '/' . $sqlFileName; $this->executeMysqlDump($sqlFilePath); // 2. ソースファイルをコピー $this->copySourceFiles($tempDir); // 3. tar.gz作成 $this->createTarGz($tempDir, $gzFilePath); // 4. ファイルサイズ検証 if (!file_exists($gzFilePath) || filesize($gzFilePath) === 0) { throw new \RuntimeException('バックアップファイルの作成に失敗しました(ファイルサイズ0)'); } $fileSizeMb = round(filesize($gzFilePath) / 1024 / 1024, 2); Log::info('JOB2 フルバックアップ完了', [ 'file' => $gzFilePath, 'size_mb' => $fileSizeMb ]); // [JOB2-STEP1] OK return [ 'status' => 'success', 'status_comment' => $backupDir . ' ' . $gzFileName ]; } catch (\Exception $e) { Log::error('JOB2 フルバックアップエラー', [ 'error' => $e->getMessage() ]); // [JOB2-STEP1] NG return [ 'status' => 'error', 'status_comment' => $e->getMessage() ]; } finally { // 一時ディレクトリを削除 if (is_dir($tempDir)) { $this->deleteDirectory($tempDir); } } } /** * mysqldump実行 * * @param string $outputPath 出力ファイルパス * @return void * @throws \RuntimeException dump失敗時 */ private function executeMysqlDump(string $outputPath): void { $mysqldumpPath = config('shj2.mysqldump_path'); $dbHost = config('database.connections.mysql.host'); $dbPort = config('database.connections.mysql.port', '3306'); $dbName = config('database.connections.mysql.database'); $dbUser = config('database.connections.mysql.username'); $dbPass = config('database.connections.mysql.password'); // mysqldumpコマンド構築(Windows環境対応) // stderrは一時ファイルに出力し、エラー時に読み取る $stderrFile = sys_get_temp_dir() . '/shj2_mysqldump_stderr.txt'; $command = sprintf( '"%s" --host="%s" --port="%s" --user="%s" --password="%s" --single-transaction --routines --triggers "%s" > "%s" 2>"%s"', $mysqldumpPath, $dbHost, $dbPort, $dbUser, $dbPass, $dbName, $outputPath, $stderrFile ); Log::info('mysqldump実行開始', ['database' => $dbName]); exec($command, $output, $returnCode); if ($returnCode !== 0) { $errorOutput = file_exists($stderrFile) ? trim(file_get_contents($stderrFile)) : '不明なエラー'; @unlink($stderrFile); throw new \RuntimeException('mysqldumpエラー: ' . $errorOutput); } @unlink($stderrFile); if (!file_exists($outputPath) || filesize($outputPath) === 0) { throw new \RuntimeException('mysqldumpの出力ファイルが空です'); } Log::info('mysqldump実行完了', [ 'output_path' => $outputPath, 'size_bytes' => filesize($outputPath) ]); } /** * ソースファイルを一時ディレクトリにコピー * * vendor等の除外ディレクトリを除いてコピーする * * @param string $tempDir コピー先一時ディレクトリ * @return void */ private function copySourceFiles(string $tempDir): void { $sourcePaths = config('shj2.source_paths', []); $excludeDirs = config('shj2.exclude_dirs', []); foreach ($sourcePaths as $sourcePath) { // 空パスはスキップ if (empty($sourcePath)) { continue; } if (!is_dir($sourcePath)) { Log::warning('ソースディレクトリが存在しません', ['path' => $sourcePath]); continue; } // ディレクトリ名を取得してコピー先を決定 $dirName = basename($sourcePath); $destDir = $tempDir . '/' . $dirName; Log::info('ソースコピー開始', [ 'source' => $sourcePath, 'dest' => $destDir ]); $this->copyDirectoryRecursive($sourcePath, $destDir, $excludeDirs); Log::info('ソースコピー完了', ['dir_name' => $dirName]); } } /** * ディレクトリを再帰的にコピー(除外ディレクトリ対応) * * @param string $source コピー元パス * @param string $dest コピー先パス * @param array $excludeDirs 除外ディレクトリ名リスト * @return void */ private function copyDirectoryRecursive(string $source, string $dest, array $excludeDirs): void { if (!is_dir($dest)) { mkdir($dest, 0755, true); } $iterator = new \DirectoryIterator($source); foreach ($iterator as $item) { if ($item->isDot()) { continue; } $itemName = $item->getFilename(); // 除外ディレクトリチェック if ($item->isDir() && in_array($itemName, $excludeDirs)) { continue; } $sourcePath = $item->getPathname(); $destPath = $dest . '/' . $itemName; if ($item->isDir()) { $this->copyDirectoryRecursive($sourcePath, $destPath, $excludeDirs); } else { copy($sourcePath, $destPath); } } } /** * tar.gzアーカイブを作成 * * @param string $sourceDir アーカイブ対象ディレクトリ * @param string $gzFilePath 出力tar.gzファイルパス * @return void * @throws \RuntimeException 作成失敗時 */ private function createTarGz(string $sourceDir, string $gzFilePath): void { Log::info('tar.gz作成開始', ['source' => $sourceDir, 'output' => $gzFilePath]); // tarファイルパス(.gzを除いたパス) $tarFilePath = preg_replace('/\.gz$/', '', $gzFilePath); try { $phar = new \PharData($tarFilePath); $phar->buildFromDirectory($sourceDir); $phar->compress(\Phar::GZ); // PharData::compress() は新しい .tar.gz ファイルを作成する // 元の .tar ファイルを削除 if (file_exists($tarFilePath)) { unlink($tarFilePath); } Log::info('tar.gz作成完了', ['output' => $gzFilePath]); } catch (\Exception $e) { // 中間ファイルのクリーンアップ if (file_exists($tarFilePath)) { unlink($tarFilePath); } if (file_exists($gzFilePath)) { unlink($gzFilePath); } throw new \RuntimeException('tar.gz作成エラー: ' . $e->getMessage()); } } /** * バッチ処理ログ作成 * * SHJ-8 バッチ処理ログ作成を呼び出す * * @param string $status ステータス(success/error) * @param string $statusComment ステータスコメント * @return void */ private function createBatchLog(string $status, string $statusComment): void { try { $device = Device::orderBy('device_id')->first(); $deviceId = $device ? $device->device_id : 1; $today = now()->format('Y/m/d'); // ステータスコメントは255文字以内に切り詰め if (mb_strlen($statusComment) > 255) { $statusComment = mb_substr($statusComment, 0, 252) . '...'; } $this->shjEightService->execute( $deviceId, null, // プロセス名 'SHJ-2データバックアップ', // ジョブ名 $status, // ステータス $statusComment, // ステータスコメント $today, // 登録日時 $today // 更新日時 ); Log::info('JOB3 バッチ処理ログ作成完了', [ 'device_id' => $deviceId, 'status' => $status ]); } catch (\Exception $e) { Log::error('JOB3 バッチ処理ログ作成エラー', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); } } /** * ディレクトリを再帰的に削除 * * @param string $dirPath 削除対象ディレクトリパス * @return void */ private function deleteDirectory(string $dirPath): void { if (!is_dir($dirPath)) { return; } $items = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($dirPath, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ($items as $item) { if ($item->isDir()) { rmdir($item->getPathname()); } else { unlink($item->getPathname()); } } rmdir($dirPath); } }