Files
sam-manage/app/Services/TriggerManagementService.php
권혁성 9eeb62f819 feat: 트리거 감사로그 operation 단위 일괄 롤백 기능
- operation 상세 페이지 및 일괄 롤백 실행 기능 추가
- TriggerAuditLog에 scopeForOperation 스코프 추가
- 트리거 INSERT/UPDATE/DELETE에 operation_id 컬럼 포함
- 감사로그 목록에 작업 단위 링크 컬럼 추가
- 라우트: operation/{id}, batch-rollback 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:22:09 +09:00

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
");
}
}