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>
This commit is contained in:
141
app/Console/Commands/ManageAuditPartitions.php
Normal file
141
app/Console/Commands/ManageAuditPartitions.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ManageAuditPartitions extends Command
|
||||
{
|
||||
protected $signature = 'audit:partitions
|
||||
{--add-months=3 : 미래 파티션 추가 개월 수}
|
||||
{--retention-months=13 : 보관 기간 (개월)}
|
||||
{--drop : 보관 기간 초과 파티션 삭제 실행}
|
||||
{--dry-run : 변경 없이 계획만 출력}';
|
||||
|
||||
protected $description = '트리거 감사 로그 파티션 자동 관리 (추가/삭제)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$addMonths = (int) $this->option('add-months');
|
||||
$retentionMonths = (int) $this->option('retention-months');
|
||||
$doDrop = $this->option('drop');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$this->info('=== 트리거 감사 로그 파티션 관리 ===');
|
||||
$this->newLine();
|
||||
|
||||
// 현재 파티션 목록 조회
|
||||
$partitions = $this->getPartitions();
|
||||
$this->info('현재 파티션: '.count($partitions).'개');
|
||||
$this->table(
|
||||
['파티션명', '상한값 (UNIX_TIMESTAMP)', '행 수'],
|
||||
collect($partitions)->map(fn ($p) => [
|
||||
$p->PARTITION_NAME,
|
||||
$p->PARTITION_DESCRIPTION === 'MAXVALUE' ? 'MAXVALUE' : Carbon::createFromTimestamp($p->PARTITION_DESCRIPTION)->format('Y-m-d'),
|
||||
number_format($p->TABLE_ROWS),
|
||||
])->toArray()
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
|
||||
// 1. 미래 파티션 추가
|
||||
$added = $this->addFuturePartitions($partitions, $addMonths, $dryRun);
|
||||
|
||||
// 2. 오래된 파티션 삭제
|
||||
$dropped = 0;
|
||||
if ($doDrop) {
|
||||
$dropped = $this->dropOldPartitions($partitions, $retentionMonths, $dryRun);
|
||||
} else {
|
||||
$this->warn('파티션 삭제는 --drop 옵션 필요');
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("결과: 추가 {$added}개, 삭제 {$dropped}개".($dryRun ? ' (dry-run)' : ''));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function getPartitions(): array
|
||||
{
|
||||
return 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')]);
|
||||
}
|
||||
|
||||
private function addFuturePartitions(array $partitions, int $addMonths, bool $dryRun): int
|
||||
{
|
||||
$existingBounds = [];
|
||||
foreach ($partitions as $p) {
|
||||
if ($p->PARTITION_DESCRIPTION !== 'MAXVALUE') {
|
||||
$existingBounds[] = (int) $p->PARTITION_DESCRIPTION;
|
||||
}
|
||||
}
|
||||
|
||||
$added = 0;
|
||||
$now = Carbon::now();
|
||||
|
||||
for ($i = 0; $i <= $addMonths; $i++) {
|
||||
$target = $now->copy()->addMonths($i)->startOfMonth()->addMonth();
|
||||
$ts = $target->timestamp;
|
||||
$name = 'p'.$target->copy()->subMonth()->format('Ym');
|
||||
|
||||
if (in_array($ts, $existingBounds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sql = "ALTER TABLE trigger_audit_logs REORGANIZE PARTITION p_future INTO (
|
||||
PARTITION {$name} VALUES LESS THAN ({$ts}),
|
||||
PARTITION p_future VALUES LESS THAN MAXVALUE
|
||||
)";
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [DRY-RUN] 추가: {$name} (< {$target->format('Y-m-d')})");
|
||||
} else {
|
||||
DB::statement($sql);
|
||||
$this->info(" 추가: {$name} (< {$target->format('Y-m-d')})");
|
||||
}
|
||||
$added++;
|
||||
}
|
||||
|
||||
if ($added === 0) {
|
||||
$this->info(' 추가할 파티션 없음');
|
||||
}
|
||||
|
||||
return $added;
|
||||
}
|
||||
|
||||
private function dropOldPartitions(array $partitions, int $retentionMonths, bool $dryRun): int
|
||||
{
|
||||
$cutoff = Carbon::now()->subMonths($retentionMonths)->startOfMonth()->timestamp;
|
||||
$dropped = 0;
|
||||
|
||||
foreach ($partitions as $p) {
|
||||
if ($p->PARTITION_DESCRIPTION === 'MAXVALUE') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bound = (int) $p->PARTITION_DESCRIPTION;
|
||||
if ($bound <= $cutoff) {
|
||||
if ($dryRun) {
|
||||
$this->line(" [DRY-RUN] 삭제: {$p->PARTITION_NAME} ({$p->TABLE_ROWS}행)");
|
||||
} else {
|
||||
DB::statement("ALTER TABLE trigger_audit_logs DROP PARTITION {$p->PARTITION_NAME}");
|
||||
$this->warn(" 삭제: {$p->PARTITION_NAME} ({$p->TABLE_ROWS}행)");
|
||||
}
|
||||
$dropped++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($dropped === 0) {
|
||||
$this->info(' 삭제할 파티션 없음');
|
||||
}
|
||||
|
||||
return $dropped;
|
||||
}
|
||||
}
|
||||
184
app/Console/Commands/RegenerateAuditTriggers.php
Normal file
184
app/Console/Commands/RegenerateAuditTriggers.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?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
|
||||
");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user