129 lines
3.7 KiB
PHP
129 lines
3.7 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Services\Audit;
|
||
|
|
|
||
|
|
use App\Models\Audit\TriggerAuditLog;
|
||
|
|
use App\Services\Service;
|
||
|
|
use Illuminate\Support\Carbon;
|
||
|
|
use Illuminate\Support\Facades\DB;
|
||
|
|
|
||
|
|
class AuditRollbackService extends Service
|
||
|
|
{
|
||
|
|
/**
|
||
|
|
* 역방향 SQL 생성 (실행하지 않음, 미리보기용)
|
||
|
|
*/
|
||
|
|
public function generateRollbackSQL(int $auditId): string
|
||
|
|
{
|
||
|
|
$log = TriggerAuditLog::findOrFail($auditId);
|
||
|
|
|
||
|
|
return match ($log->dml_type) {
|
||
|
|
'INSERT' => $this->buildDeleteSQL($log),
|
||
|
|
'UPDATE' => $this->buildRevertUpdateSQL($log),
|
||
|
|
'DELETE' => $this->buildReinsertSQL($log),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 복구 실행 (트랜잭션)
|
||
|
|
*/
|
||
|
|
public function executeRollback(int $auditId): array
|
||
|
|
{
|
||
|
|
$log = TriggerAuditLog::findOrFail($auditId);
|
||
|
|
$sql = $this->generateRollbackSQL($auditId);
|
||
|
|
|
||
|
|
DB::statement('SET @disable_audit_trigger = 1');
|
||
|
|
|
||
|
|
try {
|
||
|
|
DB::transaction(function () use ($sql) {
|
||
|
|
DB::statement($sql);
|
||
|
|
});
|
||
|
|
|
||
|
|
return [
|
||
|
|
'success' => true,
|
||
|
|
'audit_id' => $auditId,
|
||
|
|
'table_name' => $log->table_name,
|
||
|
|
'row_id' => $log->row_id,
|
||
|
|
'original_dml' => $log->dml_type,
|
||
|
|
'rollback_sql' => $sql,
|
||
|
|
];
|
||
|
|
} catch (\Throwable $e) {
|
||
|
|
return [
|
||
|
|
'success' => false,
|
||
|
|
'audit_id' => $auditId,
|
||
|
|
'error' => $e->getMessage(),
|
||
|
|
'rollback_sql' => $sql,
|
||
|
|
];
|
||
|
|
} finally {
|
||
|
|
DB::statement('SET @disable_audit_trigger = NULL');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 특정 레코드의 특정 시점 상태 조회
|
||
|
|
*/
|
||
|
|
public function getRecordStateAt(string $table, string $rowId, Carbon $at): ?array
|
||
|
|
{
|
||
|
|
$log = TriggerAuditLog::where('table_name', $table)
|
||
|
|
->where('row_id', $rowId)
|
||
|
|
->where('created_at', '<=', $at)
|
||
|
|
->orderByDesc('created_at')
|
||
|
|
->first();
|
||
|
|
|
||
|
|
if (! $log) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return match ($log->dml_type) {
|
||
|
|
'INSERT', 'UPDATE' => $log->new_values,
|
||
|
|
'DELETE' => null,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* INSERT 복구: 삽입된 레코드 삭제
|
||
|
|
*/
|
||
|
|
private function buildDeleteSQL(TriggerAuditLog $log): string
|
||
|
|
{
|
||
|
|
$rowId = DB::getPdo()->quote($log->row_id);
|
||
|
|
|
||
|
|
return "DELETE FROM `{$log->table_name}` WHERE `id` = {$rowId} LIMIT 1";
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* UPDATE 복구: old_values로 되돌림
|
||
|
|
*/
|
||
|
|
private function buildRevertUpdateSQL(TriggerAuditLog $log): string
|
||
|
|
{
|
||
|
|
if (empty($log->old_values)) {
|
||
|
|
throw new \RuntimeException("No old_values for audit #{$log->id}");
|
||
|
|
}
|
||
|
|
|
||
|
|
$pdo = DB::getPdo();
|
||
|
|
$sets = collect($log->old_values)
|
||
|
|
->map(fn ($val, $col) => "`{$col}` = ".($val === null ? 'NULL' : $pdo->quote((string) $val)))
|
||
|
|
->implode(', ');
|
||
|
|
|
||
|
|
$rowId = $pdo->quote($log->row_id);
|
||
|
|
|
||
|
|
return "UPDATE `{$log->table_name}` SET {$sets} WHERE `id` = {$rowId} LIMIT 1";
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* DELETE 복구: old_values로 재삽입
|
||
|
|
*/
|
||
|
|
private function buildReinsertSQL(TriggerAuditLog $log): string
|
||
|
|
{
|
||
|
|
if (empty($log->old_values)) {
|
||
|
|
throw new \RuntimeException("No old_values for audit #{$log->id}");
|
||
|
|
}
|
||
|
|
|
||
|
|
$pdo = DB::getPdo();
|
||
|
|
$cols = collect($log->old_values)->keys()->map(fn ($c) => "`{$c}`")->implode(', ');
|
||
|
|
$vals = collect($log->old_values)->values()
|
||
|
|
->map(fn ($v) => $v === null ? 'NULL' : $pdo->quote((string) $v))
|
||
|
|
->implode(', ');
|
||
|
|
|
||
|
|
return "INSERT INTO `{$log->table_name}` ({$cols}) VALUES ({$vals})";
|
||
|
|
}
|
||
|
|
}
|