feat:트리거 관리(4.4) + 파티션 관리(4.6) UI 구현
- TriggerManagementService: 테이블별 트리거 상태 조회/재생성/삭제 - PartitionManagementService: 파티션 현황 조회/추가/삭제 (보관기간 검증) - triggers.blade.php: 트리거 상태 대시보드 + 개별/전체 재생성·삭제 - partitions.blade.php: 파티션 통계 + 추가/삭제 (초과분만) - sub-nav: 감사 로그 목록/트리거 관리/파티션 관리 탭 내비게이션 - 라우트 6개 추가, 컨트롤러 6개 메서드 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
186
app/Services/PartitionManagementService.php
Normal file
186
app/Services/PartitionManagementService.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PartitionManagementService
|
||||
{
|
||||
/**
|
||||
* 파티션 현황 조회
|
||||
*/
|
||||
public function getPartitions(): array
|
||||
{
|
||||
$dbName = config('database.connections.mysql.database');
|
||||
$retentionDays = $this->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);
|
||||
}
|
||||
}
|
||||
251
app/Services/TriggerManagementService.php
Normal file
251
app/Services/TriggerManagementService.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TriggerManagementService
|
||||
{
|
||||
/** 트리거 제외 테이블 */
|
||||
private array $excludeTables = [
|
||||
'audit_logs',
|
||||
'trigger_audit_logs',
|
||||
'sessions',
|
||||
'cache',
|
||||
'cache_locks',
|
||||
'jobs',
|
||||
'job_batches',
|
||||
'failed_jobs',
|
||||
'migrations',
|
||||
'personal_access_tokens',
|
||||
'api_request_logs',
|
||||
];
|
||||
|
||||
/** 트리거 제외 컬럼 */
|
||||
private array $excludeColumns = [
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* 전체 테이블 + 트리거 존재 여부 매핑
|
||||
*/
|
||||
public function getTableTriggerStatus(): array
|
||||
{
|
||||
$dbName = config('database.connections.mysql.database');
|
||||
|
||||
// 모든 BASE TABLE 조회
|
||||
$allTables = collect(DB::select("
|
||||
SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE'
|
||||
ORDER BY TABLE_NAME
|
||||
", [$dbName]))->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
|
||||
");
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include('trigger-audit.partials.sub-nav')
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-5 gap-4 mb-6">
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
|
||||
17
resources/views/trigger-audit/partials/sub-nav.blade.php
Normal file
17
resources/views/trigger-audit/partials/sub-nav.blade.php
Normal file
@@ -0,0 +1,17 @@
|
||||
{{-- 트리거 감사 로그 서브 네비게이션 --}}
|
||||
<div class="mb-6 border-b border-gray-200">
|
||||
<nav class="flex gap-4 -mb-px">
|
||||
<a href="{{ route('trigger-audit.index') }}"
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 {{ request()->routeIs('trigger-audit.index') ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
|
||||
감사 로그 목록
|
||||
</a>
|
||||
<a href="{{ route('trigger-audit.triggers') }}"
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 {{ request()->routeIs('trigger-audit.triggers') ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
|
||||
트리거 관리
|
||||
</a>
|
||||
<a href="{{ route('trigger-audit.partitions') }}"
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 {{ request()->routeIs('trigger-audit.partitions') ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
|
||||
파티션 관리
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
135
resources/views/trigger-audit/partitions.blade.php
Normal file
135
resources/views/trigger-audit/partitions.blade.php
Normal file
@@ -0,0 +1,135 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '파티션 관리')
|
||||
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">파티션 관리</h1>
|
||||
</div>
|
||||
|
||||
@include('trigger-audit.partials.sub-nav')
|
||||
|
||||
<!-- 플래시 메시지 -->
|
||||
@if(session('success'))
|
||||
<div class="mb-4 p-4 bg-green-50 border border-green-200 text-green-700 rounded-lg">{{ session('success') }}</div>
|
||||
@endif
|
||||
@if(session('error'))
|
||||
<div class="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">{{ session('error') }}</div>
|
||||
@endif
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">파티션 수</div>
|
||||
<div class="text-2xl font-bold text-gray-800">{{ $data['total_partitions'] }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">전체 행 수</div>
|
||||
<div class="text-2xl font-bold text-blue-600">{{ number_format($data['total_rows']) }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">저장소 크기</div>
|
||||
<div class="text-2xl font-bold text-purple-600">{{ $data['storage_mb'] }} MB</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">보관 정책</div>
|
||||
<div class="text-2xl font-bold text-gray-800">{{ $data['retention_days'] }}일</div>
|
||||
<div class="text-xs text-gray-400 mt-1">{{ round($data['retention_days'] / 30, 1) }}개월</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 액션 폼 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<!-- 파티션 추가 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">미래 파티션 추가</h3>
|
||||
<form method="POST" action="{{ route('trigger-audit.partitions.add') }}"
|
||||
onsubmit="return confirm('파티션을 추가합니다. 계속하시겠습니까?')"
|
||||
class="flex items-end gap-3">
|
||||
@csrf
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs text-gray-500 mb-1">추가 개월 수</label>
|
||||
<input type="number" name="months" value="3" min="1" max="12"
|
||||
class="w-full border rounded-lg px-3 py-2 text-sm">
|
||||
</div>
|
||||
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm whitespace-nowrap">
|
||||
추가
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 보관 정책 안내 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">보관 정책 설정</h3>
|
||||
<div class="text-sm text-gray-600">
|
||||
<p>현재 보관기간: <strong>{{ $data['retention_days'] }}일</strong> ({{ round($data['retention_days'] / 30, 1) }}개월)</p>
|
||||
<p class="mt-2 text-xs text-gray-400">
|
||||
변경하려면 <code class="bg-gray-100 px-1 py-0.5 rounded">.env</code> 파일에서
|
||||
<code class="bg-gray-100 px-1 py-0.5 rounded">AUDIT_RETENTION_DAYS</code> 값을 수정하세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 파티션 테이블 -->
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">파티션명</th>
|
||||
<th class="px-4 py-3 text-left">날짜 범위</th>
|
||||
<th class="px-4 py-3 text-right">행 수</th>
|
||||
<th class="px-4 py-3 text-center">상태</th>
|
||||
<th class="px-4 py-3 text-center">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach($data['partitions'] as $p)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-mono text-xs">{{ $p['name'] }}</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-600">
|
||||
@if($p['bound_date'])
|
||||
~ {{ $p['bound_date'] }}
|
||||
@else
|
||||
MAXVALUE (캐치올)
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-xs text-gray-600">{{ number_format($p['rows']) }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
@switch($p['status'])
|
||||
@case('current')
|
||||
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-700">현재</span>
|
||||
@break
|
||||
@case('archived')
|
||||
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-600">보관중</span>
|
||||
@break
|
||||
@case('expired')
|
||||
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-red-100 text-red-700">초과</span>
|
||||
@break
|
||||
@case('upcoming')
|
||||
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-700">예정</span>
|
||||
@break
|
||||
@case('future')
|
||||
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-yellow-100 text-yellow-700">미래</span>
|
||||
@break
|
||||
@endswitch
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
@if($p['can_drop'])
|
||||
<form method="POST" action="{{ route('trigger-audit.partitions.drop') }}" class="inline"
|
||||
onsubmit="return confirm('⚠️ 파티션 {{ $p['name'] }}을 삭제합니다. {{ number_format($p['rows']) }}행의 데이터가 영구 삭제됩니다. 계속하시겠습니까?')">
|
||||
@csrf
|
||||
<input type="hidden" name="partition_name" value="{{ $p['name'] }}">
|
||||
<button type="submit" class="text-red-600 hover:text-red-800 text-xs px-2 py-1 rounded hover:bg-red-50">삭제</button>
|
||||
</form>
|
||||
@else
|
||||
<span class="text-gray-300 text-xs">-</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endsection
|
||||
130
resources/views/trigger-audit/triggers.blade.php
Normal file
130
resources/views/trigger-audit/triggers.blade.php
Normal file
@@ -0,0 +1,130 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '트리거 관리')
|
||||
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">트리거 관리</h1>
|
||||
</div>
|
||||
|
||||
@include('trigger-audit.partials.sub-nav')
|
||||
|
||||
<!-- 플래시 메시지 -->
|
||||
@if(session('success'))
|
||||
<div class="mb-4 p-4 bg-green-50 border border-green-200 text-green-700 rounded-lg">{{ session('success') }}</div>
|
||||
@endif
|
||||
@if(session('error'))
|
||||
<div class="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">{{ session('error') }}</div>
|
||||
@endif
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">전체 테이블</div>
|
||||
<div class="text-2xl font-bold text-gray-800">{{ $data['total_tables'] }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">추적 테이블</div>
|
||||
<div class="text-2xl font-bold text-blue-600">{{ $data['tracked_tables'] }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">제외 테이블</div>
|
||||
<div class="text-2xl font-bold text-gray-400">{{ $data['excluded_tables'] }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">활성 트리거</div>
|
||||
<div class="text-2xl font-bold text-green-600">{{ $data['active_triggers'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 벌크 액션 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-700">전체 트리거 관리</h3>
|
||||
<p class="text-xs text-gray-500 mt-1">모든 추적 대상 테이블의 트리거를 일괄 처리합니다.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<form method="POST" action="{{ route('trigger-audit.triggers.regenerate') }}"
|
||||
onsubmit="return confirm('전체 테이블의 트리거를 재생성합니다. 계속하시겠습니까?')">
|
||||
@csrf
|
||||
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm">
|
||||
전체 재생성
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('trigger-audit.triggers.drop') }}"
|
||||
onsubmit="return confirm('⚠️ 전체 테이블의 트리거를 삭제합니다. 삭제 후 감사 로그가 기록되지 않습니다. 계속하시겠습니까?')">
|
||||
@csrf
|
||||
<button type="submit" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg text-sm">
|
||||
전체 삭제
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 목록 -->
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden mb-6">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">테이블명</th>
|
||||
<th class="px-4 py-3 text-center">PK</th>
|
||||
<th class="px-4 py-3 text-center">컬럼 수</th>
|
||||
<th class="px-4 py-3 text-center">AI</th>
|
||||
<th class="px-4 py-3 text-center">AU</th>
|
||||
<th class="px-4 py-3 text-center">AD</th>
|
||||
<th class="px-4 py-3 text-center">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach($data['table_status'] as $table)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-mono text-xs">{{ $table['table_name'] }}</td>
|
||||
<td class="px-4 py-3 text-center text-xs text-gray-500">{{ $table['pk_column'] }}</td>
|
||||
<td class="px-4 py-3 text-center text-xs text-gray-500">{{ $table['column_count'] }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="inline-block w-3 h-3 rounded-full {{ $table['has_insert'] ? 'bg-green-500' : 'bg-gray-300' }}"
|
||||
title="AFTER INSERT {{ $table['has_insert'] ? '활성' : '비활성' }}"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="inline-block w-3 h-3 rounded-full {{ $table['has_update'] ? 'bg-green-500' : 'bg-gray-300' }}"
|
||||
title="AFTER UPDATE {{ $table['has_update'] ? '활성' : '비활성' }}"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="inline-block w-3 h-3 rounded-full {{ $table['has_delete'] ? 'bg-green-500' : 'bg-gray-300' }}"
|
||||
title="AFTER DELETE {{ $table['has_delete'] ? '활성' : '비활성' }}"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<div class="flex gap-1 justify-center">
|
||||
<form method="POST" action="{{ route('trigger-audit.triggers.regenerate') }}" class="inline"
|
||||
onsubmit="return confirm('{{ $table['table_name'] }} 테이블의 트리거를 재생성합니다.')">
|
||||
@csrf
|
||||
<input type="hidden" name="table_name" value="{{ $table['table_name'] }}">
|
||||
<button type="submit" class="text-blue-600 hover:text-blue-800 text-xs px-2 py-1 rounded hover:bg-blue-50">재생성</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('trigger-audit.triggers.drop') }}" class="inline"
|
||||
onsubmit="return confirm('{{ $table['table_name'] }} 테이블의 트리거를 삭제합니다.')">
|
||||
@csrf
|
||||
<input type="hidden" name="table_name" value="{{ $table['table_name'] }}">
|
||||
<button type="submit" class="text-red-600 hover:text-red-800 text-xs px-2 py-1 rounded hover:bg-red-50">삭제</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 제외 테이블 정보 -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-600 mb-2">제외 테이블 (트리거 생성 안 함)</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach($data['exclude_tables'] as $table)
|
||||
<span class="inline-flex items-center px-2 py-1 rounded text-xs bg-gray-200 text-gray-600 font-mono">{{ $table }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -278,6 +278,18 @@
|
||||
// 트리거 감사 로그 (DB 레벨 변경 추적)
|
||||
Route::prefix('trigger-audit')->name('trigger-audit.')->group(function () {
|
||||
Route::get('/', [TriggerAuditController::class, 'index'])->name('index');
|
||||
|
||||
// 4.4 트리거 관리
|
||||
Route::get('/triggers', [TriggerAuditController::class, 'triggers'])->name('triggers');
|
||||
Route::post('/triggers/regenerate', [TriggerAuditController::class, 'regenerateTrigger'])->name('triggers.regenerate');
|
||||
Route::post('/triggers/drop', [TriggerAuditController::class, 'dropTrigger'])->name('triggers.drop');
|
||||
|
||||
// 4.6 파티션 관리
|
||||
Route::get('/partitions', [TriggerAuditController::class, 'partitions'])->name('partitions');
|
||||
Route::post('/partitions/add', [TriggerAuditController::class, 'addPartitions'])->name('partitions.add');
|
||||
Route::post('/partitions/drop', [TriggerAuditController::class, 'dropPartition'])->name('partitions.drop');
|
||||
|
||||
// 기존 (동적 라우트는 정적 뒤에 배치)
|
||||
Route::get('/{id}', [TriggerAuditController::class, 'show'])->name('show')->whereNumber('id');
|
||||
Route::get('/{id}/rollback-preview', [TriggerAuditController::class, 'rollbackPreview'])->name('rollback-preview')->whereNumber('id');
|
||||
Route::post('/{id}/rollback', [TriggerAuditController::class, 'rollbackExecute'])->name('rollback-execute')->whereNumber('id');
|
||||
|
||||
Reference in New Issue
Block a user