Files
sam-api/app/Console/Commands/RegenerateAuditTriggers.php
권혁성 d07bad16df feat:DB 트리거 기반 데이터 변경 추적 시스템 구현
Phase 1: DB 기반 구축
- trigger_audit_logs 테이블 (RANGE 파티셔닝 15개, 3개 인덱스)
- 789개 MySQL AFTER 트리거 (263 테이블 × INSERT/UPDATE/DELETE)
- SetAuditSessionVariables 미들웨어 (@sam_actor_id, @sam_session_info)

Phase 2: 복구 메커니즘
- TriggerAuditLog 모델, TriggerAuditLogService, AuditRollbackService
- 6개 API 엔드포인트 (index, show, stats, history, rollback-preview, rollback)
- FormRequest 검증 (TriggerAuditLogIndexRequest, TriggerAuditRollbackRequest)

Phase 3: 관리 도구
- v_unified_audit VIEW (APP + TRIGGER 통합, COLLATE 처리)
- audit:partitions 커맨드 (파티션 추가/삭제, dry-run)
- audit:triggers 커맨드 (트리거 재생성, 테이블별/전체)
- 월 1회 파티션 자동 관리 스케줄러 등록

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

185 lines
6.7 KiB
PHP

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RegenerateAuditTriggers extends Command
{
protected $signature = 'audit:triggers
{--table= : 특정 테이블만 재생성}
{--drop-only : 트리거 삭제만 (재생성 안 함)}
{--dry-run : 변경 없이 대상 목록만 출력}';
protected $description = '트리거 감사 로그용 MySQL 트리거 재생성 (스키마 변경 후 사용)';
/** @var string[] 트리거 제외 테이블 */
private array $excludeTables = [
'audit_logs',
'trigger_audit_logs',
'sessions',
'cache',
'cache_locks',
'jobs',
'job_batches',
'failed_jobs',
'migrations',
'personal_access_tokens',
'api_request_logs',
];
public function handle(): int
{
$specificTable = $this->option('table');
$dropOnly = $this->option('drop-only');
$dryRun = $this->option('dry-run');
$dbName = config('database.connections.mysql.database');
$this->info('=== 트리거 감사 로그 트리거 '.($dropOnly ? '삭제' : '재생성').' ===');
$this->newLine();
// 대상 테이블 목록
$tables = $this->getTargetTables($dbName, $specificTable);
$this->info('대상 테이블: '.count($tables).'개');
if ($dryRun) {
foreach ($tables as $t) {
$this->line(" - {$t}");
}
$this->newLine();
$this->info('[DRY-RUN] 실제 변경 없음');
return self::SUCCESS;
}
$dropped = 0;
$created = 0;
foreach ($tables as $table) {
// 기존 트리거 삭제
foreach (['ai', 'au', 'ad'] as $suffix) {
$triggerName = "trg_{$table}_{$suffix}";
DB::unprepared("DROP TRIGGER IF EXISTS `{$triggerName}`");
$dropped++;
}
if (! $dropOnly) {
// 트리거 재생성
$this->createTriggersForTable($dbName, $table);
$created += 3;
}
$this->line(" {$table}: ".($dropOnly ? '삭제 완료' : '재생성 완료'));
}
$this->newLine();
$this->info("결과: 삭제 {$dropped}개, 생성 {$created}");
return self::SUCCESS;
}
private function getTargetTables(string $dbName, ?string $specificTable): array
{
if ($specificTable) {
if (in_array($specificTable, $this->excludeTables)) {
$this->error("{$specificTable}은(는) 트리거 제외 테이블입니다.");
return [];
}
return [$specificTable];
}
$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
{
// 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]);
$pkCol = $pkRow?->COLUMN_NAME ?? 'id';
// 컬럼 목록 (제외: created_at, updated_at, deleted_at, remember_token)
$excludeCols = ['created_at', 'updated_at', 'deleted_at', 'remember_token'];
$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, $excludeCols))
->values()
->toArray();
if (empty($cols)) {
return;
}
// JSON_OBJECT 표현식
$newJson = 'JSON_OBJECT('.collect($cols)->map(fn ($c) => "'{$c}', NEW.`{$c}`")->implode(', ').')';
$oldJson = 'JSON_OBJECT('.collect($cols)->map(fn ($c) => "'{$c}', OLD.`{$c}`")->implode(', ').')';
// changed_columns (UPDATE용)
$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';
// INSERT trigger
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
");
// UPDATE trigger
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
");
// DELETE trigger
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
");
}
}