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:
2026-02-09 09:17:15 +09:00
parent ee6794be1a
commit d07bad16df
16 changed files with 1167 additions and 1 deletions

View File

@@ -0,0 +1,128 @@
<?php
namespace App\Services\Audit;
use App\Models\Audit\TriggerAuditLog;
use App\Services\Service;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class AuditRollbackService extends Service
{
/**
* 역방향 SQL 생성 (실행하지 않음, 미리보기용)
*/
public function generateRollbackSQL(int $auditId): string
{
$log = TriggerAuditLog::findOrFail($auditId);
return match ($log->dml_type) {
'INSERT' => $this->buildDeleteSQL($log),
'UPDATE' => $this->buildRevertUpdateSQL($log),
'DELETE' => $this->buildReinsertSQL($log),
};
}
/**
* 복구 실행 (트랜잭션)
*/
public function executeRollback(int $auditId): array
{
$log = TriggerAuditLog::findOrFail($auditId);
$sql = $this->generateRollbackSQL($auditId);
DB::statement('SET @disable_audit_trigger = 1');
try {
DB::transaction(function () use ($sql) {
DB::statement($sql);
});
return [
'success' => true,
'audit_id' => $auditId,
'table_name' => $log->table_name,
'row_id' => $log->row_id,
'original_dml' => $log->dml_type,
'rollback_sql' => $sql,
];
} catch (\Throwable $e) {
return [
'success' => false,
'audit_id' => $auditId,
'error' => $e->getMessage(),
'rollback_sql' => $sql,
];
} finally {
DB::statement('SET @disable_audit_trigger = NULL');
}
}
/**
* 특정 레코드의 특정 시점 상태 조회
*/
public function getRecordStateAt(string $table, string $rowId, Carbon $at): ?array
{
$log = TriggerAuditLog::where('table_name', $table)
->where('row_id', $rowId)
->where('created_at', '<=', $at)
->orderByDesc('created_at')
->first();
if (! $log) {
return null;
}
return match ($log->dml_type) {
'INSERT', 'UPDATE' => $log->new_values,
'DELETE' => null,
};
}
/**
* INSERT 복구: 삽입된 레코드 삭제
*/
private function buildDeleteSQL(TriggerAuditLog $log): string
{
$rowId = DB::getPdo()->quote($log->row_id);
return "DELETE FROM `{$log->table_name}` WHERE `id` = {$rowId} LIMIT 1";
}
/**
* UPDATE 복구: old_values로 되돌림
*/
private function buildRevertUpdateSQL(TriggerAuditLog $log): string
{
if (empty($log->old_values)) {
throw new \RuntimeException("No old_values for audit #{$log->id}");
}
$pdo = DB::getPdo();
$sets = collect($log->old_values)
->map(fn ($val, $col) => "`{$col}` = ".($val === null ? 'NULL' : $pdo->quote((string) $val)))
->implode(', ');
$rowId = $pdo->quote($log->row_id);
return "UPDATE `{$log->table_name}` SET {$sets} WHERE `id` = {$rowId} LIMIT 1";
}
/**
* DELETE 복구: old_values로 재삽입
*/
private function buildReinsertSQL(TriggerAuditLog $log): string
{
if (empty($log->old_values)) {
throw new \RuntimeException("No old_values for audit #{$log->id}");
}
$pdo = DB::getPdo();
$cols = collect($log->old_values)->keys()->map(fn ($c) => "`{$c}`")->implode(', ');
$vals = collect($log->old_values)->values()
->map(fn ($v) => $v === null ? 'NULL' : $pdo->quote((string) $v))
->implode(', ');
return "INSERT INTO `{$log->table_name}` ({$cols}) VALUES ({$vals})";
}
}