- operation 상세 페이지 및 일괄 롤백 실행 기능 추가
- TriggerAuditLog에 scopeForOperation 스코프 추가
- 트리거 INSERT/UPDATE/DELETE에 operation_id 컬럼 포함
- 감사로그 목록에 작업 단위 링크 컬럼 추가
- 라우트: operation/{id}, batch-rollback 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
252 lines
8.9 KiB
PHP
252 lines
8.9 KiB
PHP
<?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, operation_id, db_user, created_at)
|
|
VALUES ('{$table}', NEW.`{$pkCol}`, 'INSERT', NULL, {$newJson}, NULL, {$tenantExpr}, @sam_actor_id, @sam_session_info, @sam_operation_id, 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, operation_id, db_user, created_at)
|
|
VALUES ('{$table}', NEW.`{$pkCol}`, 'UPDATE', {$oldJson}, {$newJson}, JSON_ARRAY({$changedCols}), {$tenantExpr}, @sam_actor_id, @sam_session_info, @sam_operation_id, 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, operation_id, db_user, created_at)
|
|
VALUES ('{$table}', OLD.`{$pkCol}`, 'DELETE', {$oldJson}, NULL, NULL, {$tenantExprOld}, @sam_actor_id, @sam_session_info, @sam_operation_id, CURRENT_USER(), NOW());
|
|
END IF;
|
|
END
|
|
");
|
|
}
|
|
}
|