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:
2026-02-19 11:17:45 +09:00
parent 981c3c68d4
commit 9eeb62f819
6 changed files with 282 additions and 9 deletions

View File

@@ -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 트리거 관리 ──────────────────────────────
/**

View File

@@ -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);
}
}

View File

@@ -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
");

View File

@@ -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>

View 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">&#9888;</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

View File

@@ -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');