Files
sam-manage/app/Http/Controllers/TriggerAuditController.php
권혁성 0316c63d3c feat:DB 트리거 감사 로그 관리 화면 구현
- TriggerAuditLog 모델 (casts, accessors, scopes)
- TriggerAuditController (목록/상세/이력/롤백 미리보기/롤백 실행)
- index: 대시보드 통계 + 필터 + 목록 + 파티션 현황
- show: old/new diff 뷰 (변경 컬럼 하이라이트)
- history: 레코드별 변경 타임라인
- rollback-preview: SQL 미리보기 + 확인 후 실행
- 라우트 5개 등록, 메뉴 시더 (시스템 관리 > DB 변경 추적)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 08:55:18 +09:00

229 lines
7.2 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Audit\TriggerAuditLog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class TriggerAuditController extends Controller
{
/**
* 트리거 감사 로그 목록 + 통계 대시보드
*/
public function index(Request $request): View
{
$query = TriggerAuditLog::query()->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})";
}
}