diff --git a/app/Http/Controllers/TriggerAuditController.php b/app/Http/Controllers/TriggerAuditController.php new file mode 100644 index 00000000..47adb9e3 --- /dev/null +++ b/app/Http/Controllers/TriggerAuditController.php @@ -0,0 +1,228 @@ +orderByDesc('created_at'); + + // 필터링 + if ($request->filled('table_name')) { + $query->where('table_name', $request->table_name); + } + if ($request->filled('dml_type')) { + $query->where('dml_type', $request->dml_type); + } + if ($request->filled('tenant_id')) { + $query->where('tenant_id', (int) $request->tenant_id); + } + if ($request->filled('row_id')) { + $query->where('row_id', $request->row_id); + } + if ($request->filled('from')) { + $query->where('created_at', '>=', $request->from.' 00:00:00'); + } + if ($request->filled('to')) { + $query->where('created_at', '<=', $request->to.' 23:59:59'); + } + + $logs = $query->paginate(50)->withQueryString(); + + // 통계 + $stats = $this->getStats(); + + // 테이블 목록 (필터용) + $tables = TriggerAuditLog::selectRaw('table_name, COUNT(*) as cnt') + ->groupBy('table_name') + ->orderByDesc('cnt') + ->pluck('cnt', 'table_name') + ->toArray(); + + // 파티션 현황 + $partitions = DB::select(" + SELECT PARTITION_NAME, PARTITION_DESCRIPTION, TABLE_ROWS + FROM INFORMATION_SCHEMA.PARTITIONS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'trigger_audit_logs' + AND PARTITION_NAME IS NOT NULL + ORDER BY PARTITION_ORDINAL_POSITION + ", [config('database.connections.mysql.database')]); + + // 트리거 수 + $triggerCount = DB::selectOne(" + SELECT COUNT(*) as cnt FROM INFORMATION_SCHEMA.TRIGGERS + WHERE TRIGGER_SCHEMA = ? AND TRIGGER_NAME LIKE 'trg_%' + ", [config('database.connections.mysql.database')])->cnt; + + return view('trigger-audit.index', compact('logs', 'stats', 'tables', 'partitions', 'triggerCount')); + } + + /** + * 트리거 감사 로그 상세 (diff 뷰) + */ + public function show(int $id): View + { + $log = TriggerAuditLog::findOrFail($id); + + // diff 계산 + $diff = $this->calculateDiff($log); + + return view('trigger-audit.show', compact('log', 'diff')); + } + + /** + * 특정 레코드의 변경 이력 + */ + public function recordHistory(string $tableName, string $rowId): View + { + $logs = TriggerAuditLog::forRecord($tableName, $rowId) + ->orderByDesc('created_at') + ->paginate(50); + + return view('trigger-audit.history', compact('logs', 'tableName', 'rowId')); + } + + /** + * 롤백 미리보기 + */ + public function rollbackPreview(int $id): View + { + $log = TriggerAuditLog::findOrFail($id); + $sql = $this->generateRollbackSQL($log); + + return view('trigger-audit.rollback-preview', compact('log', 'sql')); + } + + /** + * 롤백 실행 + */ + public function rollbackExecute(Request $request, int $id) + { + $request->validate(['confirm' => 'required|accepted']); + + $log = TriggerAuditLog::findOrFail($id); + $sql = $this->generateRollbackSQL($log); + + DB::statement('SET @disable_audit_trigger = 1'); + + try { + DB::transaction(function () use ($sql) { + DB::statement($sql); + }); + + return redirect()->route('trigger-audit.show', $id) + ->with('success', '롤백이 성공적으로 실행되었습니다.'); + } catch (\Throwable $e) { + return redirect()->route('trigger-audit.rollback-preview', $id) + ->with('error', '롤백 실패: '.$e->getMessage()); + } finally { + DB::statement('SET @disable_audit_trigger = NULL'); + } + } + + private function getStats(): array + { + $total = TriggerAuditLog::count(); + + $byDmlType = TriggerAuditLog::selectRaw('dml_type, COUNT(*) as count') + ->groupBy('dml_type') + ->pluck('count', 'dml_type') + ->toArray(); + + $today = TriggerAuditLog::whereDate('created_at', today())->count(); + + $topTables = TriggerAuditLog::selectRaw('table_name, COUNT(*) as count') + ->groupBy('table_name') + ->orderByDesc('count') + ->limit(10) + ->pluck('count', 'table_name') + ->toArray(); + + // 저장소 크기 + $storageInfo = DB::selectOne(" + SELECT + ROUND(SUM(DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2) as size_mb + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'trigger_audit_logs' + ", [config('database.connections.mysql.database')]); + + return [ + 'total' => $total, + 'today' => $today, + 'by_dml_type' => $byDmlType, + 'top_tables' => $topTables, + 'storage_mb' => $storageInfo->size_mb ?? 0, + ]; + } + + private function calculateDiff(TriggerAuditLog $log): array + { + $old = $log->old_values ?? []; + $new = $log->new_values ?? []; + $allKeys = array_unique(array_merge(array_keys($old), array_keys($new))); + sort($allKeys); + + $diff = []; + foreach ($allKeys as $key) { + $oldVal = $old[$key] ?? null; + $newVal = $new[$key] ?? null; + $changed = $oldVal !== $newVal; + + $diff[] = [ + 'column' => $key, + 'old' => $oldVal, + 'new' => $newVal, + 'changed' => $changed, + ]; + } + + return $diff; + } + + private function generateRollbackSQL(TriggerAuditLog $log): string + { + $pdo = DB::getPdo(); + + return match ($log->dml_type) { + 'INSERT' => "DELETE FROM `{$log->table_name}` WHERE `id` = ".$pdo->quote($log->row_id).' LIMIT 1', + 'UPDATE' => $this->buildRevertUpdateSQL($log, $pdo), + 'DELETE' => $this->buildReinsertSQL($log, $pdo), + }; + } + + private function buildRevertUpdateSQL(TriggerAuditLog $log, \PDO $pdo): string + { + if (empty($log->old_values)) { + return '-- old_values 없음, 롤백 불가'; + } + + $sets = collect($log->old_values) + ->map(fn ($val, $col) => "`{$col}` = ".($val === null ? 'NULL' : $pdo->quote((string) $val))) + ->implode(', '); + + return "UPDATE `{$log->table_name}` SET {$sets} WHERE `id` = ".$pdo->quote($log->row_id).' LIMIT 1'; + } + + private function buildReinsertSQL(TriggerAuditLog $log, \PDO $pdo): string + { + if (empty($log->old_values)) { + return '-- old_values 없음, 롤백 불가'; + } + + $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})"; + } +} diff --git a/app/Models/Audit/TriggerAuditLog.php b/app/Models/Audit/TriggerAuditLog.php new file mode 100644 index 00000000..8968bb7a --- /dev/null +++ b/app/Models/Audit/TriggerAuditLog.php @@ -0,0 +1,54 @@ + 'array', + 'new_values' => 'array', + 'changed_columns' => 'array', + 'created_at' => 'datetime', + ]; + + /** + * changed_columns에서 NULL 값 제거 + */ + public function getChangedColumnsAttribute($value): ?array + { + $decoded = is_string($value) ? json_decode($value, true) : $value; + if (! is_array($decoded)) { + return null; + } + + return array_values(array_filter($decoded, fn ($v) => $v !== null)); + } + + /** + * session_info JSON → array + */ + public function getSessionInfoAttribute($value): ?array + { + if (! $value) { + return null; + } + + return is_string($value) ? json_decode($value, true) : $value; + } + + public function scopeForTable($query, string $tableName) + { + return $query->where('table_name', $tableName); + } + + public function scopeForRecord($query, string $tableName, string $rowId) + { + return $query->where('table_name', $tableName)->where('row_id', $rowId); + } +} diff --git a/database/seeders/TriggerAuditMenuSeeder.php b/database/seeders/TriggerAuditMenuSeeder.php new file mode 100644 index 00000000..4836bcf6 --- /dev/null +++ b/database/seeders/TriggerAuditMenuSeeder.php @@ -0,0 +1,65 @@ +where('name', '시스템 관리') + ->first(); + + if (! $parentMenu) { + $this->command->error('시스템 관리 메뉴를 찾을 수 없습니다.'); + + return; + } + + // 이미 존재하는지 확인 + $existingMenu = Menu::where('tenant_id', $tenantId) + ->where('name', 'DB 변경 추적') + ->where('parent_id', $parentMenu->id) + ->first(); + + if ($existingMenu) { + $this->command->info('DB 변경 추적 메뉴가 이미 존재합니다.'); + + return; + } + + // 현재 자식 메뉴 최대 sort_order 확인 + $maxSort = Menu::where('parent_id', $parentMenu->id) + ->max('sort_order') ?? 0; + + // 메뉴 생성 + $menu = Menu::create([ + 'tenant_id' => $tenantId, + 'parent_id' => $parentMenu->id, + 'name' => 'DB 변경 추적', + 'url' => '/trigger-audit', + 'icon' => 'database', + 'sort_order' => $maxSort + 1, + 'is_active' => true, + ]); + + $this->command->info("메뉴 생성 완료: {$menu->name} (sort_order: {$menu->sort_order})"); + + // 하위 메뉴 목록 출력 + $this->command->info(''); + $this->command->info('=== 시스템 관리 하위 메뉴 ==='); + $children = Menu::where('parent_id', $parentMenu->id) + ->orderBy('sort_order') + ->get(['name', 'url', 'sort_order']); + + foreach ($children as $child) { + $this->command->info("{$child->sort_order}. {$child->name} ({$child->url})"); + } + } +} diff --git a/resources/views/trigger-audit/history.blade.php b/resources/views/trigger-audit/history.blade.php new file mode 100644 index 00000000..321c734a --- /dev/null +++ b/resources/views/trigger-audit/history.blade.php @@ -0,0 +1,60 @@ +@extends('layouts.app') + +@section('title', '레코드 변경 이력') + +@section('content') +
+ {{ $tableName }} / Row + {{ $rowId }} +
+| 파티션 | +행 수 | +
|---|---|
| {{ $p->PARTITION_NAME }} | +{{ number_format($p->TABLE_ROWS) }} | +
| ID | +테이블 | +Row ID | +DML | +변경 컬럼 | +DB 사용자 | +일시 | +액션 | +
|---|---|---|---|---|---|---|---|
| {{ $log->id }} | ++ {{ $log->table_name }} + | ++ {{ $log->row_id }} + | ++ + {{ $log->dml_type }} + + | ++ @if($log->changed_columns) + {{ implode(', ', array_slice($log->changed_columns, 0, 3)) }} + @if(count($log->changed_columns) > 3) + +{{ count($log->changed_columns) - 3 }} + @endif + @else + - + @endif + | +{{ $log->db_user ?? '-' }} | +{{ $log->created_at->format('m/d H:i:s') }} | ++ 상세 + | +
| 감사 로그가 없습니다. | +|||||||
+ 감사 로그 #{{ $log->id }} / + {{ $log->table_name }} / Row + {{ $log->row_id }} +
+{{ $sql }}
+
+ * 감사 트리거를 비활성화한 상태에서 실행됩니다 (@disable_audit_trigger = 1)
+
{{ $log->table_name }} / Row {{ $log->row_id }}
+| 컬럼 | +이전 값 (OLD) | +이후 값 (NEW) | +
|---|---|---|
| + {{ $d['column'] }} + @if($d['changed']) * @endif + | +
+ @if($d['old'] === null)
+ NULL
+ @elseif(is_array($d['old']) || is_object($d['old']))
+ {{ json_encode($d['old'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}
+ @else
+ {{ Str::limit((string) $d['old'], 500) }}
+ @endif
+ |
+
+ @if($d['new'] === null)
+ NULL
+ @elseif(is_array($d['new']) || is_object($d['new']))
+ {{ json_encode($d['new'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}
+ @else
+ {{ Str::limit((string) $d['new'], 500) }}
+ @endif
+ |
+