feat:DB 트리거 기반 데이터 변경 추적 시스템 구현
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>
This commit is contained in:
128
app/Services/Audit/AuditRollbackService.php
Normal file
128
app/Services/Audit/AuditRollbackService.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?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})";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user