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})"; } // ── 4.4 트리거 관리 ────────────────────────────── /** * 트리거 관리 페이지 */ public function triggers(): View { $data = $this->triggerService->getTableTriggerStatus(); return view('trigger-audit.triggers', compact('data')); } /** * 트리거 재생성 (단일 또는 전체) */ public function regenerateTrigger(Request $request) { $tableName = $request->input('table_name'); try { $result = $this->triggerService->regenerate($tableName); } catch (\Throwable $e) { return redirect()->route('trigger-audit.triggers') ->with('error', '트리거 재생성 실패: '.$e->getMessage()); } return redirect()->route('trigger-audit.triggers') ->with($result['success'] ? 'success' : 'error', $result['message']); } /** * 트리거 삭제 (단일 또는 전체) */ public function dropTrigger(Request $request) { $tableName = $request->input('table_name'); try { $result = $this->triggerService->drop($tableName); } catch (\Throwable $e) { return redirect()->route('trigger-audit.triggers') ->with('error', '트리거 삭제 실패: '.$e->getMessage()); } return redirect()->route('trigger-audit.triggers') ->with($result['success'] ? 'success' : 'error', $result['message']); } // ── 4.6 파티션 관리 ────────────────────────────── /** * 파티션 관리 페이지 */ public function partitions(): View { $data = $this->partitionService->getPartitions(); return view('trigger-audit.partitions', compact('data')); } /** * 미래 파티션 추가 */ public function addPartitions(Request $request) { $request->validate(['months' => 'required|integer|min:1|max:12']); try { $result = $this->partitionService->addFuturePartitions((int) $request->months); } catch (\Throwable $e) { return redirect()->route('trigger-audit.partitions') ->with('error', '파티션 추가 실패: '.$e->getMessage()); } return redirect()->route('trigger-audit.partitions') ->with($result['success'] ? 'success' : 'error', $result['message']); } /** * 파티션 삭제 */ public function dropPartition(Request $request) { $request->validate(['partition_name' => 'required|string']); try { $result = $this->partitionService->dropPartition($request->partition_name); } catch (\Throwable $e) { return redirect()->route('trigger-audit.partitions') ->with('error', '파티션 삭제 실패: '.$e->getMessage()); } return redirect()->route('trigger-audit.partitions') ->with($result['success'] ? 'success' : 'error', $result['message']); } }