Phase 1: DB 기반 구축 - trigger_audit_logs 테이블 (RANGE 파티셔닝 15개, 3개 인덱스) - 789개 MySQL AFTER 트리거 (263 테이블 × INSERT/UPDATE/DELETE) - SetAuditSessionVariables 미들웨어 (@sam_actor_id, @sam_session_info) Phase 2: 복구 메커니즘 - TriggerAuditLog 모델, TriggerAuditLogService, AuditRollbackService - 6개 API 엔드포인트 (index, show, stats, history, rollback-preview, rollback) - FormRequest 검증 (TriggerAuditLogIndexRequest, TriggerAuditRollbackRequest) Phase 3: 관리 도구 - v_unified_audit VIEW (APP + TRIGGER 통합, COLLATE 처리) - audit:partitions 커맨드 (파티션 추가/삭제, dry-run) - audit:triggers 커맨드 (트리거 재생성, 테이블별/전체) - 월 1회 파티션 자동 관리 스케줄러 등록 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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})";
|
|
}
|
|
}
|