- operation 상세 페이지 및 일괄 롤백 실행 기능 추가
- TriggerAuditLog에 scopeForOperation 스코프 추가
- 트리거 INSERT/UPDATE/DELETE에 operation_id 컬럼 포함
- 감사로그 목록에 작업 단위 링크 컬럼 추가
- 라우트: operation/{id}, batch-rollback 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
412 lines
13 KiB
PHP
412 lines
13 KiB
PHP
<?php
|
|
|
|
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,
|
|
) {}
|
|
|
|
/**
|
|
* 트리거 감사 로그 목록 + 통계 대시보드
|
|
*/
|
|
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})";
|
|
}
|
|
|
|
// ── 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 트리거 관리 ──────────────────────────────
|
|
|
|
/**
|
|
* 트리거 관리 페이지
|
|
*/
|
|
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']);
|
|
}
|
|
}
|