api.so-manager-dev.com/app/Services/ShjTwoService.php
unhi.go e1073e2577
All checks were successful
Deploy api / deploy (push) Successful in 24s
SH-6 SHJ-9 実装
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:16:47 +08:00

471 lines
15 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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);
}
}