From 9eeb62f81953ee165da0f524dfaededddda21cb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 19 Feb 2026 11:17:45 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=EB=A1=9C=EA=B7=B8=20operation=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=84=20=EB=A1=A4=EB=B0=B1=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - operation 상세 페이지 및 일괄 롤백 실행 기능 추가 - TriggerAuditLog에 scopeForOperation 스코프 추가 - 트리거 INSERT/UPDATE/DELETE에 operation_id 컬럼 포함 - 감사로그 목록에 작업 단위 링크 컬럼 추가 - 라우트: operation/{id}, batch-rollback 추가 Co-Authored-By: Claude Opus 4.6 --- .../Controllers/TriggerAuditController.php | 80 ++++++++ app/Models/Audit/TriggerAuditLog.php | 5 + app/Services/TriggerManagementService.php | 12 +- resources/views/trigger-audit/index.blade.php | 16 +- .../views/trigger-audit/operation.blade.php | 174 ++++++++++++++++++ routes/web.php | 4 + 6 files changed, 282 insertions(+), 9 deletions(-) create mode 100644 resources/views/trigger-audit/operation.blade.php diff --git a/app/Http/Controllers/TriggerAuditController.php b/app/Http/Controllers/TriggerAuditController.php index 4a5c5679..898f95f9 100644 --- a/app/Http/Controllers/TriggerAuditController.php +++ b/app/Http/Controllers/TriggerAuditController.php @@ -233,6 +233,86 @@ private function buildReinsertSQL(TriggerAuditLog $log, \PDO $pdo): string return "INSERT INTO `{$log->table_name}` ({$cols}) VALUES ({$vals})"; } + // ── 4.3 일괄 롤백 (Operation 단위) ────────────────── + + /** + * Operation 단위 상세 (일괄 롤백 미리보기) + */ + public function operation(string $operationId): View + { + $logs = TriggerAuditLog::forOperation($operationId) + ->orderBy('created_at') + ->orderBy('id') + ->get(); + + if ($logs->isEmpty()) { + abort(404, '해당 작업 ID의 로그가 없습니다.'); + } + + // 각 로그별 롤백 SQL 생성 + $rollbackItems = $logs->reverse()->map(function (TriggerAuditLog $log) { + return [ + 'log' => $log, + 'sql' => $this->generateRollbackSQL($log), + 'diff' => $this->calculateDiff($log), + ]; + }); + + // 요약 정보 + $summary = [ + 'operation_id' => $operationId, + 'total_count' => $logs->count(), + 'tables' => $logs->groupBy('table_name')->map->count(), + 'dml_types' => $logs->groupBy('dml_type')->map->count(), + 'actor_id' => $logs->first()->actor_id, + 'session_info' => $logs->first()->session_info, + 'started_at' => $logs->first()->created_at, + 'ended_at' => $logs->last()->created_at, + ]; + + return view('trigger-audit.operation', compact('rollbackItems', 'summary')); + } + + /** + * Operation 단위 일괄 롤백 실행 + */ + public function batchRollbackExecute(Request $request, string $operationId) + { + $request->validate(['confirm' => 'required|accepted']); + + $logs = TriggerAuditLog::forOperation($operationId) + ->orderBy('created_at') + ->orderBy('id') + ->get(); + + if ($logs->isEmpty()) { + abort(404, '해당 작업 ID의 로그가 없습니다.'); + } + + // 역순으로 롤백 SQL 생성 + $sqls = $logs->reverse()->map(fn (TriggerAuditLog $log) => $this->generateRollbackSQL($log)); + + DB::statement('SET @disable_audit_trigger = 1'); + + try { + DB::transaction(function () use ($sqls) { + foreach ($sqls as $sql) { + if (! str_starts_with($sql, '--')) { + DB::statement($sql); + } + } + }); + + return redirect()->route('trigger-audit.operation', $operationId) + ->with('success', "일괄 롤백이 성공적으로 실행되었습니다. ({$logs->count()}건)"); + } catch (\Throwable $e) { + return redirect()->route('trigger-audit.operation', $operationId) + ->with('error', '일괄 롤백 실패: '.$e->getMessage()); + } finally { + DB::statement('SET @disable_audit_trigger = NULL'); + } + } + // ── 4.4 트리거 관리 ────────────────────────────── /** diff --git a/app/Models/Audit/TriggerAuditLog.php b/app/Models/Audit/TriggerAuditLog.php index 8968bb7a..877410c3 100644 --- a/app/Models/Audit/TriggerAuditLog.php +++ b/app/Models/Audit/TriggerAuditLog.php @@ -51,4 +51,9 @@ public function scopeForRecord($query, string $tableName, string $rowId) { return $query->where('table_name', $tableName)->where('row_id', $rowId); } + + public function scopeForOperation($query, string $operationId) + { + return $query->where('operation_id', $operationId); + } } diff --git a/app/Services/TriggerManagementService.php b/app/Services/TriggerManagementService.php index 249280fd..232af49d 100644 --- a/app/Services/TriggerManagementService.php +++ b/app/Services/TriggerManagementService.php @@ -220,8 +220,8 @@ private function createTriggersForTable(string $dbName, string $table): void CREATE TRIGGER `trg_{$table}_ai` AFTER INSERT ON `{$table}` FOR EACH ROW BEGIN {$guard} - INSERT INTO trigger_audit_logs (table_name, row_id, dml_type, old_values, new_values, changed_columns, tenant_id, actor_id, session_info, db_user, created_at) - VALUES ('{$table}', NEW.`{$pkCol}`, 'INSERT', NULL, {$newJson}, NULL, {$tenantExpr}, @sam_actor_id, @sam_session_info, CURRENT_USER(), NOW()); + INSERT INTO trigger_audit_logs (table_name, row_id, dml_type, old_values, new_values, changed_columns, tenant_id, actor_id, session_info, operation_id, db_user, created_at) + VALUES ('{$table}', NEW.`{$pkCol}`, 'INSERT', NULL, {$newJson}, NULL, {$tenantExpr}, @sam_actor_id, @sam_session_info, @sam_operation_id, CURRENT_USER(), NOW()); END IF; END "); @@ -231,8 +231,8 @@ private function createTriggersForTable(string $dbName, string $table): void FOR EACH ROW BEGIN {$guard} IF {$changeCheck} THEN - INSERT INTO trigger_audit_logs (table_name, row_id, dml_type, old_values, new_values, changed_columns, tenant_id, actor_id, session_info, db_user, created_at) - VALUES ('{$table}', NEW.`{$pkCol}`, 'UPDATE', {$oldJson}, {$newJson}, JSON_ARRAY({$changedCols}), {$tenantExpr}, @sam_actor_id, @sam_session_info, CURRENT_USER(), NOW()); + INSERT INTO trigger_audit_logs (table_name, row_id, dml_type, old_values, new_values, changed_columns, tenant_id, actor_id, session_info, operation_id, db_user, created_at) + VALUES ('{$table}', NEW.`{$pkCol}`, 'UPDATE', {$oldJson}, {$newJson}, JSON_ARRAY({$changedCols}), {$tenantExpr}, @sam_actor_id, @sam_session_info, @sam_operation_id, CURRENT_USER(), NOW()); END IF; END IF; END @@ -242,8 +242,8 @@ private function createTriggersForTable(string $dbName, string $table): void CREATE TRIGGER `trg_{$table}_ad` AFTER DELETE ON `{$table}` FOR EACH ROW BEGIN {$guard} - INSERT INTO trigger_audit_logs (table_name, row_id, dml_type, old_values, new_values, changed_columns, tenant_id, actor_id, session_info, db_user, created_at) - VALUES ('{$table}', OLD.`{$pkCol}`, 'DELETE', {$oldJson}, NULL, NULL, {$tenantExprOld}, @sam_actor_id, @sam_session_info, CURRENT_USER(), NOW()); + INSERT INTO trigger_audit_logs (table_name, row_id, dml_type, old_values, new_values, changed_columns, tenant_id, actor_id, session_info, operation_id, db_user, created_at) + VALUES ('{$table}', OLD.`{$pkCol}`, 'DELETE', {$oldJson}, NULL, NULL, {$tenantExprOld}, @sam_actor_id, @sam_session_info, @sam_operation_id, CURRENT_USER(), NOW()); END IF; END "); diff --git a/resources/views/trigger-audit/index.blade.php b/resources/views/trigger-audit/index.blade.php index 20df7a93..bdce3b92 100644 --- a/resources/views/trigger-audit/index.blade.php +++ b/resources/views/trigger-audit/index.blade.php @@ -135,7 +135,7 @@ class="border rounded-lg px-3 py-2 text-sm w-32"> Row ID DML 변경 컬럼 - DB 사용자 + 작업 단위 일시 액션 @@ -170,7 +170,17 @@ class="text-blue-600 hover:underline">{{ $log->row_id }} - @endif - {{ $log->db_user ?? '-' }} + + @if($log->operation_id) + + {{ substr($log->operation_id, 0, 8) }} + + @else + - + @endif + {{ $log->created_at->format('m/d H:i:s') }} 상세 @empty - 감사 로그가 없습니다. + 감사 로그가 없습니다. @endforelse diff --git a/resources/views/trigger-audit/operation.blade.php b/resources/views/trigger-audit/operation.blade.php new file mode 100644 index 00000000..c8c6dda1 --- /dev/null +++ b/resources/views/trigger-audit/operation.blade.php @@ -0,0 +1,174 @@ +@extends('layouts.app') + +@section('title', '작업 단위 상세') + +@section('content') +
+
+

작업 단위 상세

+

+ Operation ID: {{ $summary['operation_id'] }} +

+
+ 목록 +
+ +@if(session('success')) +
{{ session('success') }}
+@endif +@if(session('error')) +
{{ session('error') }}
+@endif + + +
+

작업 요약

+
+
+ 총 변경 건수 +
{{ $summary['total_count'] }}건
+
+
+ 영향받은 테이블 +
+ @foreach($summary['tables'] as $table => $count) + + {{ $table }} ({{ $count }}) + + @endforeach +
+
+
+ DML 유형 +
+ @foreach($summary['dml_types'] as $type => $count) + + {{ $type }} ({{ $count }}) + + @endforeach +
+
+
+ 시간 범위 +
+ {{ $summary['started_at']->format('Y-m-d H:i:s') }} + @if($summary['started_at'] != $summary['ended_at']) +
~ {{ $summary['ended_at']->format('H:i:s') }} + @endif +
+
+ @if($summary['actor_id']) +
+ Actor ID +
{{ $summary['actor_id'] }}
+
+ @endif + @if($summary['session_info']) +
+ IP / Route +
+ {{ $summary['session_info']['ip'] ?? '-' }} / + {{ $summary['session_info']['route'] ?? '-' }} +
+
+ @endif +
+
+ + +
+

+ 변경 내역 (롤백 실행 순서) +

+
+ @foreach($rollbackItems as $idx => $item) + @php $log = $item['log']; @endphp +
+
+
+ + {{ $loop->iteration }} + + + {{ $log->dml_type }} + + + {{ $log->table_name }}.{{ $log->row_id }} + + + #{{ $log->id }} + +
+
+ + @if($log->dml_type === 'INSERT') DELETE + @elseif($log->dml_type === 'UPDATE') REVERT + @elseif($log->dml_type === 'DELETE') RE-INSERT + @endif + + 상세 +
+
+ + +
+
{{ $item['sql'] }}
+
+ + + @if($log->changed_columns) +
+ @foreach($log->changed_columns as $col) + {{ $col }} + @endforeach +
+ @endif +
+ @endforeach +
+
+ + +
+
+ +
+

일괄 롤백 주의사항

+
    +
  • 위 {{ $summary['total_count'] }}건의 SQL이 하나의 트랜잭션으로 실행됩니다.
  • +
  • 롤백은 역순(마지막 변경 → 첫 번째 변경)으로 실행됩니다.
  • +
  • 롤백 이후 해당 레코드에 추가 변경이 있었으면 데이터 충돌이 발생할 수 있습니다.
  • +
  • 실패 시 전체 트랜잭션이 롤백되어 데이터는 변경되지 않습니다.
  • +
  • 운영 환경에서는 반드시 백업 후 실행하세요.
  • +
+
+
+
+ + +
+
+ @csrf +
+ + +
+
+
+@endsection \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index df06d3b7..1008a39e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -302,6 +302,10 @@ Route::post('/partitions/add', [TriggerAuditController::class, 'addPartitions'])->name('partitions.add'); Route::post('/partitions/drop', [TriggerAuditController::class, 'dropPartition'])->name('partitions.drop'); + // 작업 단위 일괄 롤백 + Route::get('/operation/{operationId}', [TriggerAuditController::class, 'operation'])->name('operation'); + Route::post('/operation/{operationId}/batch-rollback', [TriggerAuditController::class, 'batchRollbackExecute'])->name('batch-rollback'); + // 기존 (동적 라우트는 정적 뒤에 배치) Route::get('/{id}', [TriggerAuditController::class, 'show'])->name('show')->whereNumber('id'); Route::get('/{id}/rollback-preview', [TriggerAuditController::class, 'rollbackPreview'])->name('rollback-preview')->whereNumber('id');