diff --git a/app/Http/Controllers/TriggerAuditController.php b/app/Http/Controllers/TriggerAuditController.php index 47adb9e3..4a5c5679 100644 --- a/app/Http/Controllers/TriggerAuditController.php +++ b/app/Http/Controllers/TriggerAuditController.php @@ -3,12 +3,19 @@ namespace App\Http\Controllers; use App\Models\Audit\TriggerAuditLog; +use App\Services\PartitionManagementService; +use App\Services\TriggerManagementService; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\View\View; class TriggerAuditController extends Controller { + public function __construct( + private TriggerManagementService $triggerService, + private PartitionManagementService $partitionService, + ) {} + /** * 트리거 감사 로그 목록 + 통계 대시보드 */ @@ -225,4 +232,100 @@ private function buildReinsertSQL(TriggerAuditLog $log, \PDO $pdo): string 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']); + } } diff --git a/app/Services/PartitionManagementService.php b/app/Services/PartitionManagementService.php new file mode 100644 index 00000000..f4bec8fb --- /dev/null +++ b/app/Services/PartitionManagementService.php @@ -0,0 +1,186 @@ +getRetentionDays(); + + $partitions = DB::select(" + SELECT PARTITION_NAME, PARTITION_DESCRIPTION, TABLE_ROWS, DATA_LENGTH, INDEX_LENGTH + FROM INFORMATION_SCHEMA.PARTITIONS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'trigger_audit_logs' + AND PARTITION_NAME IS NOT NULL + ORDER BY PARTITION_ORDINAL_POSITION + ", [$dbName]); + + // 저장소 전체 크기 + $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' + ", [$dbName]); + + $totalRows = 0; + $cutoffTs = Carbon::now()->subDays($retentionDays)->timestamp; + $nowTs = Carbon::now()->timestamp; + + $items = []; + foreach ($partitions as $p) { + $totalRows += $p->TABLE_ROWS; + + if ($p->PARTITION_DESCRIPTION === 'MAXVALUE') { + $items[] = [ + 'name' => $p->PARTITION_NAME, + 'bound_raw' => 'MAXVALUE', + 'bound_date' => null, + 'rows' => $p->TABLE_ROWS, + 'status' => 'future', + 'can_drop' => false, + ]; + + continue; + } + + $boundTs = (int) $p->PARTITION_DESCRIPTION; + $boundDate = Carbon::createFromTimestamp($boundTs)->format('Y-m-d'); + + // 상태 결정 + if ($boundTs <= $cutoffTs) { + $status = 'expired'; // 보관기간 초과 + } elseif ($boundTs <= $nowTs) { + $prevMonthTs = Carbon::now()->startOfMonth()->timestamp; + $status = $boundTs <= $prevMonthTs ? 'archived' : 'current'; + } else { + $status = 'upcoming'; // 미래 파티션 + } + + $items[] = [ + 'name' => $p->PARTITION_NAME, + 'bound_raw' => $boundTs, + 'bound_date' => $boundDate, + 'rows' => $p->TABLE_ROWS, + 'status' => $status, + 'can_drop' => $status === 'expired', + ]; + } + + return [ + 'partitions' => $items, + 'total_partitions' => count($items), + 'total_rows' => $totalRows, + 'storage_mb' => $storageInfo->size_mb ?? 0, + 'retention_days' => $retentionDays, + ]; + } + + /** + * 미래 파티션 추가 (REORGANIZE PARTITION p_future) + */ + public function addFuturePartitions(int $months): array + { + $dbName = config('database.connections.mysql.database'); + + // 현재 파티션 바운드 조회 + $partitions = DB::select(" + SELECT PARTITION_NAME, PARTITION_DESCRIPTION + FROM INFORMATION_SCHEMA.PARTITIONS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'trigger_audit_logs' + AND PARTITION_NAME IS NOT NULL + ORDER BY PARTITION_ORDINAL_POSITION + ", [$dbName]); + + $existingBounds = []; + foreach ($partitions as $p) { + if ($p->PARTITION_DESCRIPTION !== 'MAXVALUE') { + $existingBounds[] = (int) $p->PARTITION_DESCRIPTION; + } + } + + $added = 0; + $now = Carbon::now(); + + for ($i = 0; $i <= $months; $i++) { + $target = $now->copy()->addMonths($i)->startOfMonth()->addMonth(); + $ts = $target->timestamp; + $name = 'p' . $target->copy()->subMonth()->format('Ym'); + + if (in_array($ts, $existingBounds)) { + continue; + } + + DB::statement("ALTER TABLE trigger_audit_logs REORGANIZE PARTITION p_future INTO ( + PARTITION {$name} VALUES LESS THAN ({$ts}), + PARTITION p_future VALUES LESS THAN MAXVALUE + )"); + + $added++; + } + + if ($added === 0) { + return ['success' => true, 'message' => '추가할 파티션이 없습니다 (이미 존재).']; + } + + return ['success' => true, 'message' => "파티션 {$added}개 추가 완료."]; + } + + /** + * 파티션 삭제 (보관기간 초과 검증 포함) + */ + public function dropPartition(string $name): array + { + if ($name === 'p_future') { + return ['success' => false, 'message' => 'p_future 파티션은 삭제할 수 없습니다.']; + } + + $dbName = config('database.connections.mysql.database'); + $retentionDays = $this->getRetentionDays(); + + // 파티션 존재 및 바운드 확인 + $partition = DB::selectOne(" + SELECT PARTITION_NAME, PARTITION_DESCRIPTION, TABLE_ROWS + FROM INFORMATION_SCHEMA.PARTITIONS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'trigger_audit_logs' + AND PARTITION_NAME = ? + ", [$dbName, $name]); + + if (! $partition) { + return ['success' => false, 'message' => "파티션 '{$name}'을 찾을 수 없습니다."]; + } + + if ($partition->PARTITION_DESCRIPTION === 'MAXVALUE') { + return ['success' => false, 'message' => '이 파티션은 삭제할 수 없습니다.']; + } + + // 보관기간 초과 여부 서버측 검증 + $boundTs = (int) $partition->PARTITION_DESCRIPTION; + $cutoffTs = Carbon::now()->subDays($retentionDays)->timestamp; + + if ($boundTs > $cutoffTs) { + return ['success' => false, 'message' => '보관기간이 초과되지 않은 파티션은 삭제할 수 없습니다.']; + } + + $rows = $partition->TABLE_ROWS; + DB::statement("ALTER TABLE trigger_audit_logs DROP PARTITION {$name}"); + + return ['success' => true, 'message' => "파티션 '{$name}' 삭제 완료 ({$rows}행)."]; + } + + /** + * 보관기간 (일) + */ + public function getRetentionDays(): int + { + return (int) env('AUDIT_RETENTION_DAYS', 395); + } +} diff --git a/app/Services/TriggerManagementService.php b/app/Services/TriggerManagementService.php new file mode 100644 index 00000000..249280fd --- /dev/null +++ b/app/Services/TriggerManagementService.php @@ -0,0 +1,251 @@ +pluck('TABLE_NAME')->toArray(); + + // 추적 대상 테이블 + $trackedTables = array_values(array_diff($allTables, $this->excludeTables)); + + // 현재 존재하는 트리거 조회 + $triggers = collect(DB::select(" + SELECT TRIGGER_NAME, EVENT_OBJECT_TABLE, EVENT_MANIPULATION + FROM INFORMATION_SCHEMA.TRIGGERS + WHERE TRIGGER_SCHEMA = ? AND TRIGGER_NAME LIKE 'trg_%' + ORDER BY EVENT_OBJECT_TABLE, EVENT_MANIPULATION + ", [$dbName])); + + // 테이블별 트리거 상태 매핑 + $tableStatus = []; + foreach ($trackedTables as $table) { + // PK 조회 + $pkRow = DB::selectOne(" + SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_KEY = 'PRI' + LIMIT 1 + ", [$dbName, $table]); + + // 컬럼 수 조회 + $colCount = DB::selectOne(" + SELECT COUNT(*) as cnt FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + ", [$dbName, $table])->cnt; + + $tableTriggers = $triggers->where('EVENT_OBJECT_TABLE', $table); + + $tableStatus[] = [ + 'table_name' => $table, + 'pk_column' => $pkRow?->COLUMN_NAME ?? '-', + 'column_count' => $colCount, + 'has_insert' => $tableTriggers->contains('EVENT_MANIPULATION', 'INSERT'), + 'has_update' => $tableTriggers->contains('EVENT_MANIPULATION', 'UPDATE'), + 'has_delete' => $tableTriggers->contains('EVENT_MANIPULATION', 'DELETE'), + ]; + } + + return [ + 'total_tables' => count($allTables), + 'tracked_tables' => count($trackedTables), + 'excluded_tables' => count($this->excludeTables), + 'active_triggers' => $triggers->count(), + 'table_status' => $tableStatus, + 'exclude_tables' => $this->excludeTables, + ]; + } + + /** + * 트리거 재생성 (단일 테이블 또는 전체) + */ + public function regenerate(?string $tableName = null): array + { + $dbName = config('database.connections.mysql.database'); + $tables = $this->getTargetTables($dbName, $tableName); + + if (empty($tables)) { + return ['success' => false, 'message' => '대상 테이블이 없습니다.']; + } + + $dropped = 0; + $created = 0; + + foreach ($tables as $table) { + // 기존 트리거 삭제 + foreach (['ai', 'au', 'ad'] as $suffix) { + DB::unprepared("DROP TRIGGER IF EXISTS `trg_{$table}_{$suffix}`"); + $dropped++; + } + + // 트리거 생성 + $this->createTriggersForTable($dbName, $table); + $created += 3; + } + + $label = $tableName ?? '전체'; + + return [ + 'success' => true, + 'message' => "[{$label}] 트리거 재생성 완료 (삭제: {$dropped}, 생성: {$created})", + ]; + } + + /** + * 트리거 삭제 (단일 테이블 또는 전체) + */ + public function drop(?string $tableName = null): array + { + $dbName = config('database.connections.mysql.database'); + $tables = $this->getTargetTables($dbName, $tableName); + + if (empty($tables)) { + return ['success' => false, 'message' => '대상 테이블이 없습니다.']; + } + + $dropped = 0; + + foreach ($tables as $table) { + foreach (['ai', 'au', 'ad'] as $suffix) { + DB::unprepared("DROP TRIGGER IF EXISTS `trg_{$table}_{$suffix}`"); + $dropped++; + } + } + + $label = $tableName ?? '전체'; + + return [ + 'success' => true, + 'message' => "[{$label}] 트리거 삭제 완료 ({$dropped}개)", + ]; + } + + private function getTargetTables(string $dbName, ?string $tableName): array + { + if ($tableName) { + if (in_array($tableName, $this->excludeTables)) { + return []; + } + + return [$tableName]; + } + + $rows = DB::select(" + SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE' + ORDER BY TABLE_NAME + ", [$dbName]); + + return collect($rows) + ->pluck('TABLE_NAME') + ->reject(fn ($t) => in_array($t, $this->excludeTables)) + ->values() + ->toArray(); + } + + private function createTriggersForTable(string $dbName, string $table): void + { + $pkRow = DB::selectOne(" + SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_KEY = 'PRI' + LIMIT 1 + ", [$dbName, $table]); + + $pkCol = $pkRow?->COLUMN_NAME ?? 'id'; + + $columns = DB::select(' + SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + ORDER BY ORDINAL_POSITION + ', [$dbName, $table]); + + $cols = collect($columns) + ->pluck('COLUMN_NAME') + ->reject(fn ($c) => in_array($c, $this->excludeColumns)) + ->values() + ->toArray(); + + if (empty($cols)) { + return; + } + + $newJson = 'JSON_OBJECT(' . collect($cols)->map(fn ($c) => "'{$c}', NEW.`{$c}`")->implode(', ') . ')'; + $oldJson = 'JSON_OBJECT(' . collect($cols)->map(fn ($c) => "'{$c}', OLD.`{$c}`")->implode(', ') . ')'; + + $changedCols = collect($cols)->map(fn ($c) => "IF(NOT (NEW.`{$c}` <=> OLD.`{$c}`), '{$c}', NULL)")->implode(', '); + $changeCheck = collect($cols)->map(fn ($c) => "NOT (NEW.`{$c}` <=> OLD.`{$c}`)")->implode(' OR '); + + $tenantExpr = in_array('tenant_id', $cols) ? 'NEW.`tenant_id`' : 'NULL'; + $tenantExprOld = in_array('tenant_id', $cols) ? 'OLD.`tenant_id`' : 'NULL'; + + $guard = 'IF @disable_audit_trigger IS NULL OR @disable_audit_trigger != 1 THEN'; + + DB::unprepared(" + 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()); + END IF; + END + "); + + DB::unprepared(" + CREATE TRIGGER `trg_{$table}_au` AFTER UPDATE ON `{$table}` + 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()); + END IF; + END IF; + END + "); + + DB::unprepared(" + 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()); + END IF; + END + "); + } +} diff --git a/resources/views/trigger-audit/index.blade.php b/resources/views/trigger-audit/index.blade.php index d0045971..20df7a93 100644 --- a/resources/views/trigger-audit/index.blade.php +++ b/resources/views/trigger-audit/index.blade.php @@ -11,6 +11,8 @@ +@include('trigger-audit.partials.sub-nav') +
현재 보관기간: {{ $data['retention_days'] }}일 ({{ round($data['retention_days'] / 30, 1) }}개월)
+
+ 변경하려면 .env 파일에서
+ AUDIT_RETENTION_DAYS 값을 수정하세요.
+
| 파티션명 | +날짜 범위 | +행 수 | +상태 | +액션 | +
|---|---|---|---|---|
| {{ $p['name'] }} | ++ @if($p['bound_date']) + ~ {{ $p['bound_date'] }} + @else + MAXVALUE (캐치올) + @endif + | +{{ number_format($p['rows']) }} | ++ @switch($p['status']) + @case('current') + 현재 + @break + @case('archived') + 보관중 + @break + @case('expired') + 초과 + @break + @case('upcoming') + 예정 + @break + @case('future') + 미래 + @break + @endswitch + | ++ @if($p['can_drop']) + + @else + - + @endif + | +
모든 추적 대상 테이블의 트리거를 일괄 처리합니다.
+| 테이블명 | +PK | +컬럼 수 | +AI | +AU | +AD | +액션 | +
|---|---|---|---|---|---|---|
| {{ $table['table_name'] }} | +{{ $table['pk_column'] }} | +{{ $table['column_count'] }} | ++ + | ++ + | ++ + | +
+
+
+
+
+ |
+