All checks were successful
Deploy api / deploy (push) Successful in 24s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
471 lines
15 KiB
PHP
471 lines
15 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\Device;
|
||
use App\Services\ShjEightService;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Carbon\Carbon;
|
||
|
||
/**
|
||
* SHJ-2 データバックアップサービス
|
||
*
|
||
* 概要: データベースの夜間自動フルバックアップ。5世代保持。
|
||
*
|
||
* 処理フロー:
|
||
* <JOB1> 現時点の世代シフトを行う
|
||
* <JOB2> フルバックアップを行う(ソース + DB)
|
||
* <JOB3> バッチ処理ログを作成する(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);
|
||
|
||
// <JOB1> 世代シフト
|
||
$this->shiftGenerations($backupRoot, $dbName, $generations);
|
||
|
||
// <JOB2> フルバックアップ
|
||
$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()
|
||
]);
|
||
}
|
||
|
||
// <JOB3> バッチ処理ログ作成(成功・失敗に関わらず実行)
|
||
$this->createBatchLog($status, $statusComment);
|
||
|
||
return [
|
||
'success' => $status === 'success',
|
||
'status' => $status,
|
||
'status_comment' => $statusComment
|
||
];
|
||
}
|
||
|
||
/**
|
||
* <JOB1> 世代シフト処理
|
||
*
|
||
* ・5世代前 削除
|
||
* ・4世代前 5世代前へ移動
|
||
* ・3世代前 4世代前へ移動
|
||
* ・2世代前 3世代前へ移動
|
||
* ・1世代前(<DB名>直下)→ 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(<DB名>直下)→ 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 世代シフト完了');
|
||
}
|
||
|
||
/**
|
||
* <JOB2> フルバックアップ実行
|
||
*
|
||
* 保存内容: {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());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* <JOB3> バッチ処理ログ作成
|
||
*
|
||
* 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);
|
||
}
|
||
}
|