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,97 @@
<?php
namespace App\Services\Audit;
use App\Models\Audit\TriggerAuditLog;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class TriggerAuditLogService extends Service
{
public function paginate(array $filters): LengthAwarePaginator
{
$page = (int) ($filters['page'] ?? 1);
$size = (int) ($filters['size'] ?? 20);
$sort = $filters['sort'] ?? 'created_at';
$order = $filters['order'] ?? 'desc';
$q = TriggerAuditLog::query();
// 테넌트 필터 (선택적: 전체 조회도 가능)
if (! empty($filters['tenant_id'])) {
$q->where('tenant_id', (int) $filters['tenant_id']);
}
if (! empty($filters['table_name'])) {
$q->where('table_name', $filters['table_name']);
}
if (! empty($filters['row_id'])) {
$q->where('row_id', $filters['row_id']);
}
if (! empty($filters['dml_type'])) {
$q->where('dml_type', $filters['dml_type']);
}
if (! empty($filters['actor_id'])) {
$q->where('actor_id', (int) $filters['actor_id']);
}
if (! empty($filters['db_user'])) {
$q->where('db_user', 'like', "%{$filters['db_user']}%");
}
if (! empty($filters['from'])) {
$q->where('created_at', '>=', $filters['from']);
}
if (! empty($filters['to'])) {
$q->where('created_at', '<=', $filters['to']);
}
return $q->orderBy($sort, $order)->paginate($size, ['*'], 'page', $page);
}
/**
* 특정 레코드의 변경 이력 조회
*/
public function recordHistory(string $tableName, string $rowId): \Illuminate\Support\Collection
{
return TriggerAuditLog::forRecord($tableName, $rowId)
->orderByDesc('created_at')
->get();
}
/**
* 테이블별 변경 통계
*/
public function stats(?int $tenantId = null): array
{
$q = TriggerAuditLog::query();
if ($tenantId) {
$q->where('tenant_id', $tenantId);
}
$total = (clone $q)->count();
$byDmlType = (clone $q)
->selectRaw('dml_type, COUNT(*) as count')
->groupBy('dml_type')
->pluck('count', 'dml_type')
->toArray();
$topTables = (clone $q)
->selectRaw('table_name, COUNT(*) as count')
->groupBy('table_name')
->orderByDesc('count')
->limit(10)
->pluck('count', 'table_name')
->toArray();
$today = (clone $q)
->whereDate('created_at', today())
->count();
return [
'total' => $total,
'today' => $today,
'by_dml_type' => $byDmlType,
'top_tables' => $topTables,
];
}
}