feat: 트리거 감사로그 operation 단위 일괄 롤백 기능
- operation 상세 페이지 및 일괄 롤백 실행 기능 추가
- TriggerAuditLog에 scopeForOperation 스코프 추가
- 트리거 INSERT/UPDATE/DELETE에 operation_id 컬럼 포함
- 감사로그 목록에 작업 단위 링크 컬럼 추가
- 라우트: operation/{id}, batch-rollback 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 트리거 관리 ──────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
");
|
||||
|
||||
@@ -135,7 +135,7 @@ class="border rounded-lg px-3 py-2 text-sm w-32">
|
||||
<th class="px-4 py-3 text-left">Row ID</th>
|
||||
<th class="px-4 py-3 text-center">DML</th>
|
||||
<th class="px-4 py-3 text-left">변경 컬럼</th>
|
||||
<th class="px-4 py-3 text-left">DB 사용자</th>
|
||||
<th class="px-4 py-3 text-center">작업 단위</th>
|
||||
<th class="px-4 py-3 text-left">일시</th>
|
||||
<th class="px-4 py-3 text-center">액션</th>
|
||||
</tr>
|
||||
@@ -170,7 +170,17 @@ class="text-blue-600 hover:underline">{{ $log->row_id }}</a>
|
||||
<span class="text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 text-xs">{{ $log->db_user ?? '-' }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
@if($log->operation_id)
|
||||
<a href="{{ route('trigger-audit.operation', $log->operation_id) }}"
|
||||
class="inline-block bg-purple-100 text-purple-700 px-2 py-0.5 rounded text-xs hover:bg-purple-200"
|
||||
title="{{ $log->operation_id }}">
|
||||
{{ substr($log->operation_id, 0, 8) }}
|
||||
</a>
|
||||
@else
|
||||
<span class="text-gray-400 text-xs">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 text-xs">{{ $log->created_at->format('m/d H:i:s') }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<a href="{{ route('trigger-audit.show', $log->id) }}"
|
||||
@@ -179,7 +189,7 @@ class="text-blue-600 hover:underline text-xs">상세</a>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="8" class="px-4 py-8 text-center text-gray-400">감사 로그가 없습니다.</td>
|
||||
<td colspan="9" class="px-4 py-8 text-center text-gray-400">감사 로그가 없습니다.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
|
||||
174
resources/views/trigger-audit/operation.blade.php
Normal file
174
resources/views/trigger-audit/operation.blade.php
Normal file
@@ -0,0 +1,174 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '작업 단위 상세')
|
||||
|
||||
@section('content')
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">작업 단위 상세</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Operation ID: <span class="font-mono">{{ $summary['operation_id'] }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{ route('trigger-audit.index') }}"
|
||||
class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm">목록</a>
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="bg-green-100 text-green-700 px-4 py-3 rounded-lg mb-6">{{ session('success') }}</div>
|
||||
@endif
|
||||
@if(session('error'))
|
||||
<div class="bg-red-100 text-red-700 px-4 py-3 rounded-lg mb-6">{{ session('error') }}</div>
|
||||
@endif
|
||||
|
||||
<!-- 요약 정보 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">작업 요약</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500">총 변경 건수</span>
|
||||
<div class="mt-1 text-2xl font-bold text-blue-600">{{ $summary['total_count'] }}건</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">영향받은 테이블</span>
|
||||
<div class="mt-1">
|
||||
@foreach($summary['tables'] as $table => $count)
|
||||
<span class="inline-block bg-gray-100 text-gray-700 px-2 py-0.5 rounded text-xs mr-1 mb-1">
|
||||
{{ $table }} ({{ $count }})
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">DML 유형</span>
|
||||
<div class="mt-1">
|
||||
@foreach($summary['dml_types'] as $type => $count)
|
||||
<span class="inline-block px-2 py-0.5 rounded text-xs mr-1 mb-1
|
||||
{{ $type === 'INSERT' ? 'bg-green-100 text-green-700' : '' }}
|
||||
{{ $type === 'UPDATE' ? 'bg-yellow-100 text-yellow-700' : '' }}
|
||||
{{ $type === 'DELETE' ? 'bg-red-100 text-red-700' : '' }}">
|
||||
{{ $type }} ({{ $count }})
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">시간 범위</span>
|
||||
<div class="mt-1 text-xs font-medium">
|
||||
{{ $summary['started_at']->format('Y-m-d H:i:s') }}
|
||||
@if($summary['started_at'] != $summary['ended_at'])
|
||||
<br>~ {{ $summary['ended_at']->format('H:i:s') }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@if($summary['actor_id'])
|
||||
<div>
|
||||
<span class="text-gray-500">Actor ID</span>
|
||||
<div class="mt-1 font-medium">{{ $summary['actor_id'] }}</div>
|
||||
</div>
|
||||
@endif
|
||||
@if($summary['session_info'])
|
||||
<div>
|
||||
<span class="text-gray-500">IP / Route</span>
|
||||
<div class="mt-1 text-xs font-medium">
|
||||
{{ $summary['session_info']['ip'] ?? '-' }} /
|
||||
{{ $summary['session_info']['route'] ?? '-' }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 변경 내역 목록 (롤백 역순) -->
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 px-4 py-3 bg-gray-50 border-b">
|
||||
변경 내역 (롤백 실행 순서)
|
||||
</h3>
|
||||
<div class="divide-y divide-gray-100">
|
||||
@foreach($rollbackItems as $idx => $item)
|
||||
@php $log = $item['log']; @endphp
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="bg-gray-200 text-gray-600 text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center">
|
||||
{{ $loop->iteration }}
|
||||
</span>
|
||||
<span class="px-2 py-0.5 rounded text-xs font-medium
|
||||
{{ $log->dml_type === 'INSERT' ? 'bg-green-100 text-green-700' : '' }}
|
||||
{{ $log->dml_type === 'UPDATE' ? 'bg-yellow-100 text-yellow-700' : '' }}
|
||||
{{ $log->dml_type === 'DELETE' ? 'bg-red-100 text-red-700' : '' }}">
|
||||
{{ $log->dml_type }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-800">
|
||||
{{ $log->table_name }}.{{ $log->row_id }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">
|
||||
#{{ $log->id }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-orange-600 font-medium">
|
||||
@if($log->dml_type === 'INSERT') DELETE
|
||||
@elseif($log->dml_type === 'UPDATE') REVERT
|
||||
@elseif($log->dml_type === 'DELETE') RE-INSERT
|
||||
@endif
|
||||
</span>
|
||||
<a href="{{ route('trigger-audit.show', $log->id) }}"
|
||||
class="text-blue-500 hover:text-blue-700 text-xs">상세</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 롤백 SQL -->
|
||||
<div class="bg-gray-900 text-green-400 p-3 rounded text-xs font-mono overflow-x-auto">
|
||||
<pre class="whitespace-pre-wrap">{{ $item['sql'] }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- 변경 컬럼 (UPDATE인 경우) -->
|
||||
@if($log->changed_columns)
|
||||
<div class="mt-2">
|
||||
@foreach($log->changed_columns as $col)
|
||||
<span class="inline-block bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded text-xs mr-1">{{ $col }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 경고 -->
|
||||
<div class="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-orange-500 text-xl">⚠</span>
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-orange-700">일괄 롤백 주의사항</h4>
|
||||
<ul class="text-sm text-orange-600 mt-1 space-y-1">
|
||||
<li>위 {{ $summary['total_count'] }}건의 SQL이 하나의 트랜잭션으로 실행됩니다.</li>
|
||||
<li>롤백은 역순(마지막 변경 → 첫 번째 변경)으로 실행됩니다.</li>
|
||||
<li>롤백 이후 해당 레코드에 추가 변경이 있었으면 데이터 충돌이 발생할 수 있습니다.</li>
|
||||
<li>실패 시 전체 트랜잭션이 롤백되어 데이터는 변경되지 않습니다.</li>
|
||||
<li>운영 환경에서는 반드시 백업 후 실행하세요.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 일괄 롤백 실행 폼 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<form method="POST" action="{{ route('trigger-audit.batch-rollback', $summary['operation_id']) }}"
|
||||
onsubmit="return confirm('정말로 {{ $summary['total_count'] }}건의 일괄 롤백을 실행하시겠습니까?\n이 작업은 되돌릴 수 없습니다.')">
|
||||
@csrf
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="confirm" value="1" required
|
||||
class="rounded border-gray-300 text-orange-600 focus:ring-orange-500">
|
||||
<span class="text-gray-700">위 {{ $summary['total_count'] }}건의 SQL을 실행하여 데이터를 일괄 롤백합니다.</span>
|
||||
</label>
|
||||
<button type="submit"
|
||||
class="bg-orange-500 hover:bg-orange-600 text-white px-6 py-2 rounded-lg text-sm font-medium">
|
||||
일괄 롤백 실행 ({{ $summary['total_count'] }}건)
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user