From 785e3674722c262e11299586720b7856be93bbfc Mon Sep 17 00:00:00 2001 From: hskwon Date: Thu, 11 Sep 2025 14:39:55 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=86=B5=ED=95=A9=20=EA=B0=90=EC=82=AC?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API/=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB: 감사 로그 테이블(audit_logs) 마이그레이션 및 인덱스 추가 - Config: audit.php 추가(AUDIT_RETENTION_DAYS, AUDIT_LOG_READS 토글) - Model/Service: AuditLog 모델, AuditLogger 서비스 생성 - 도메인 훅: ModelVersion.release(released), BomTemplate upsert/update/delete/replaceItems/clone 기록, diff 조회는 설정 기반 기록 - API: GET /api/v1/design/audit-logs 추가(FormRequest/Service/Controller, 필터 page/size/target_type/target_id/action/actor_id/from/to/sort/order) - Swagger: 감사 로그 조회 문서 추가(Design Audit 태그) - Console: audit:prune 커맨드 추가 및 스케줄러 매일 03:10 실행 등록(시스템 크론 schedule:run 필요) - Fix: PruneAuditLogs import 충돌 제거(Google ServiceControl AuditLog 제거) --- app/Console/Commands/PruneAuditLogs.php | 24 ++++++ app/Console/Kernel.php | 26 ++++++ .../Api/V1/Design/AuditLogController.php | 23 ++++++ .../Requests/Audit/AuditLogIndexRequest.php | 29 +++++++ app/Models/Audit/AuditLog.php | 22 +++++ app/Services/Audit/AuditLogService.php | 43 ++++++++++ app/Services/Audit/AuditLogger.php | 42 ++++++++++ app/Services/Design/BomTemplateService.php | 82 ++++++++++++++++++- app/Services/Design/ModelVersionService.php | 15 ++++ app/Swagger/v1/AuditLogApi.php | 54 ++++++++++++ config/audit.php | 9 ++ ...5_09_11_000100_create_audit_logs_table.php | 32 ++++++++ routes/api.php | 4 + 13 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/PruneAuditLogs.php create mode 100644 app/Console/Kernel.php create mode 100644 app/Http/Controllers/Api/V1/Design/AuditLogController.php create mode 100644 app/Http/Requests/Audit/AuditLogIndexRequest.php create mode 100644 app/Models/Audit/AuditLog.php create mode 100644 app/Services/Audit/AuditLogService.php create mode 100644 app/Services/Audit/AuditLogger.php create mode 100644 app/Swagger/v1/AuditLogApi.php create mode 100644 config/audit.php create mode 100644 database/migrations/2025_09_11_000100_create_audit_logs_table.php diff --git a/app/Console/Commands/PruneAuditLogs.php b/app/Console/Commands/PruneAuditLogs.php new file mode 100644 index 0000000..3d2273a --- /dev/null +++ b/app/Console/Commands/PruneAuditLogs.php @@ -0,0 +1,24 @@ +option('days') ?? config('audit.retention_days', 395)); + $cutoff = Carbon::now()->subDays($days); + + $count = AuditLog::query()->where('created_at', '<', $cutoff)->delete(); + $this->info("Pruned {$count} audit log rows older than {$days} days."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php new file mode 100644 index 0000000..2a1a6e3 --- /dev/null +++ b/app/Console/Kernel.php @@ -0,0 +1,26 @@ +command('audit:prune')->dailyAt('03:10'); + } + + protected function commands(): void + { + // 라우트 콘솔 혹은 커맨드 자동 로딩 필요 시 사용 + // $this->load(__DIR__.'/Commands'); + } +} diff --git a/app/Http/Controllers/Api/V1/Design/AuditLogController.php b/app/Http/Controllers/Api/V1/Design/AuditLogController.php new file mode 100644 index 0000000..3b6f4ae --- /dev/null +++ b/app/Http/Controllers/Api/V1/Design/AuditLogController.php @@ -0,0 +1,23 @@ +validated(); + return $this->service->paginate($filters); + }, __('message.fetched')); + } +} diff --git a/app/Http/Requests/Audit/AuditLogIndexRequest.php b/app/Http/Requests/Audit/AuditLogIndexRequest.php new file mode 100644 index 0000000..1fa51e3 --- /dev/null +++ b/app/Http/Requests/Audit/AuditLogIndexRequest.php @@ -0,0 +1,29 @@ + 'nullable|integer|min:1', + 'size' => 'nullable|integer|min:1|max:200', + 'target_type' => 'nullable|string|max:100', + 'target_id' => 'nullable|integer|min:1', + 'action' => 'nullable|string|max:50', + 'actor_id' => 'nullable|integer|min:1', + 'from' => 'nullable|date', + 'to' => 'nullable|date|after_or_equal:from', + 'sort' => 'nullable|string|in:created_at', + 'order' => 'nullable|string|in:asc,desc', + ]; + } +} diff --git a/app/Models/Audit/AuditLog.php b/app/Models/Audit/AuditLog.php new file mode 100644 index 0000000..1bf5ec1 --- /dev/null +++ b/app/Models/Audit/AuditLog.php @@ -0,0 +1,22 @@ + 'array', + 'after' => 'array', + 'created_at' => 'datetime', + ]; +} diff --git a/app/Services/Audit/AuditLogService.php b/app/Services/Audit/AuditLogService.php new file mode 100644 index 0000000..583f8f4 --- /dev/null +++ b/app/Services/Audit/AuditLogService.php @@ -0,0 +1,43 @@ +tenantId(); + + $page = (int)($filters['page'] ?? 1); + $size = (int)($filters['size'] ?? 20); + $sort = $filters['sort'] ?? 'created_at'; + $order = $filters['order'] ?? 'desc'; + + $q = AuditLog::query()->where('tenant_id', $tenantId); + + if (!empty($filters['target_type'])) { + $q->where('target_type', $filters['target_type']); + } + if (!empty($filters['target_id'])) { + $q->where('target_id', (int)$filters['target_id']); + } + if (!empty($filters['action'])) { + $q->where('action', $filters['action']); + } + if (!empty($filters['actor_id'])) { + $q->where('actor_id', (int)$filters['actor_id']); + } + 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); + } +} diff --git a/app/Services/Audit/AuditLogger.php b/app/Services/Audit/AuditLogger.php new file mode 100644 index 0000000..e83f439 --- /dev/null +++ b/app/Services/Audit/AuditLogger.php @@ -0,0 +1,42 @@ +user())->id ?? null; + $ip = $request?->ip(); + $ua = $request?->userAgent(); + + AuditLog::create([ + 'tenant_id' => $tenantId, + 'target_type' => $targetType, + 'target_id' => $targetId, + 'action' => $action, + 'before' => $before, + 'after' => $after, + 'actor_id' => $actorId, + 'ip' => $ip, + 'ua' => $ua, + 'created_at' => now(), + ]); + } catch (\Throwable $e) { + // 감사 로그 실패는 업무 흐름을 방해하지 않음 + } + } +} diff --git a/app/Services/Design/BomTemplateService.php b/app/Services/Design/BomTemplateService.php index 575a769..9b632ab 100644 --- a/app/Services/Design/BomTemplateService.php +++ b/app/Services/Design/BomTemplateService.php @@ -51,6 +51,9 @@ public function upsertTemplate(int $modelVersionId, string $name = 'Main', bool ->where('name', $name) ->first(); + $action = 'created'; + $before = null; + if (!$tpl) { $tpl = BomTemplate::create([ 'tenant_id' => $tenantId, @@ -60,6 +63,8 @@ public function upsertTemplate(int $modelVersionId, string $name = 'Main', bool 'notes' => $notes, ]); } else { + $action = 'updated'; + $before = $tpl->toArray(); $tpl->fill(['is_primary' => $isPrimary, 'notes' => $notes])->save(); } @@ -70,6 +75,16 @@ public function upsertTemplate(int $modelVersionId, string $name = 'Main', bool ->update(['is_primary' => false]); } + // 감사 로그 + app(\App\Services\Audit\AuditLogger::class)->log( + tenantId: $tenantId, + targetType: 'bom_template', + targetId: $tpl->id, + action: $action, + before: $before, + after: $tpl->toArray() + ); + return $tpl; }); } @@ -99,6 +114,7 @@ public function updateTemplate(int $templateId, array $data): BomTemplate } } + $before = $tpl->toArray(); $tpl->fill($data)->save(); if (array_key_exists('is_primary', $data) && $data['is_primary']) { @@ -109,6 +125,16 @@ public function updateTemplate(int $templateId, array $data): BomTemplate ->update(['is_primary' => false]); } + // 감사 로그 + app(\App\Services\Audit\AuditLogger::class)->log( + tenantId: $tenantId, + targetType: 'bom_template', + targetId: $tpl->id, + action: 'updated', + before: $before, + after: $tpl->toArray() + ); + return $tpl; } @@ -125,7 +151,18 @@ public function deleteTemplate(int $templateId): void throw new NotFoundHttpException(__('error.not_found')); } + $before = $tpl->toArray(); $tpl->delete(); + + // 감사 로그 + app(\App\Services\Audit\AuditLogger::class)->log( + tenantId: $tenantId, + targetType: 'bom_template', + targetId: $tpl->id, + action: 'deleted', + before: $before, + after: null + ); } /** 템플릿 상세 (옵션: 항목 포함) */ @@ -214,6 +251,26 @@ public function replaceItems(int $templateId, array $items): void if (!empty($payloads)) { BomTemplateItem::insert($payloads); } + + // 감사 로그 + app(\App\Services\Audit\AuditLogger::class)->log( + tenantId: $tenantId, + targetType: 'bom_template', + targetId: $tpl->id, + action: 'items_replaced', + before: ['items' => $beforeItems], + after: ['items' => array_map(function ($p) { + return [ + 'ref_type' => $p['ref_type'], + 'ref_id' => $p['ref_id'], + 'qty' => (float) $p['qty'], + 'waste_rate' => (float) $p['waste_rate'], + 'uom_id' => $p['uom_id'], + 'notes' => $p['notes'], + 'sort_order' => $p['sort_order'], + ]; + }, $payloads)] + ); }); } @@ -309,7 +366,7 @@ public function diffTemplates(int $leftTemplateId, int $rightTemplateId): array } } - return [ + $result = [ 'left_template_id' => $left->id, 'right_template_id' => $right->id, 'summary' => [ @@ -321,6 +378,19 @@ public function diffTemplates(int $leftTemplateId, int $rightTemplateId): array 'removed' => $removed, 'changed' => $changed, ]; + + if (config('audit.log_reads', false)) { + app(\App\Services\Audit\AuditLogger::class)->log( + tenantId: $tenantId, + targetType: 'bom_template', + targetId: $left->id, + action: 'diff_viewed', + before: ['left' => $left->id, 'right' => $right->id], + after: ['summary' => $result['summary']] + ); + } + + return $result; } /** 템플릿 복제(깊은 복사) */ @@ -394,6 +464,16 @@ public function cloneTemplate(int $templateId, ?int $targetVersionId = null, ?st ->update(['is_primary' => false]); } + // 감사 로그 + app(\App\Services\Audit\AuditLogger::class)->log( + tenantId: $tenantId, + targetType: 'bom_template', + targetId: $dest->id, + action: 'cloned', + before: ['source_template_id' => $src->id], + after: $dest->toArray() + ); + return $dest; }); } diff --git a/app/Services/Design/ModelVersionService.php b/app/Services/Design/ModelVersionService.php index 16d0459..363598f 100644 --- a/app/Services/Design/ModelVersionService.php +++ b/app/Services/Design/ModelVersionService.php @@ -95,10 +95,25 @@ public function release(int $versionId): ModelVersion // 릴리즈 전 유효성 검사 app(\App\Services\Design\BomTemplateService::class)->validateForRelease($mv->id); + $before = [ + 'status' => $mv->status, + 'effective_from' => $mv->effective_from ? $mv->effective_from->toISOString() : null, + ]; + $mv->status = 'RELEASED'; $mv->effective_from = $mv->effective_from ?? now(); $mv->save(); + // 감사 로그 + app(\App\Services\Audit\AuditLogger::class)->log( + tenantId: $tenantId, + targetType: 'model_version', + targetId: $mv->id, + action: 'released', + before: $before, + after: ['status' => $mv->status, 'effective_from' => $mv->effective_from->toISOString()] + ); + return $mv; } } diff --git a/app/Swagger/v1/AuditLogApi.php b/app/Swagger/v1/AuditLogApi.php new file mode 100644 index 0000000..ecea023 --- /dev/null +++ b/app/Swagger/v1/AuditLogApi.php @@ -0,0 +1,54 @@ + env('AUDIT_RETENTION_DAYS', 395), // 기본 13개월 + + // 조회(diff) 로그 기록 여부(기본 비활성). 개발/QA에서만 활성화 권장 + 'log_reads' => env('AUDIT_LOG_READS', false), +]; diff --git a/database/migrations/2025_09_11_000100_create_audit_logs_table.php b/database/migrations/2025_09_11_000100_create_audit_logs_table.php new file mode 100644 index 0000000..e18e671 --- /dev/null +++ b/database/migrations/2025_09_11_000100_create_audit_logs_table.php @@ -0,0 +1,32 @@ +bigIncrements('id'); + $table->unsignedBigInteger('tenant_id')->comment('테넌트ID'); + $table->string('target_type', 100)->comment('리소스 타입 예: model_version, bom_template'); + $table->unsignedBigInteger('target_id')->nullable()->comment('리소스 ID'); + $table->string('action', 50)->comment('액션: created/updated/deleted/released/cloned/items_replaced/diff_viewed'); + $table->json('before')->nullable()->comment('변경 전 스냅샷'); + $table->json('after')->nullable()->comment('변경 후 스냅샷'); + $table->unsignedBigInteger('actor_id')->nullable()->comment('사용자ID(옵션)'); + $table->string('ip', 45)->nullable()->comment('요청 IP'); + $table->string('ua', 255)->nullable()->comment('User-Agent'); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['tenant_id', 'target_type', 'target_id', 'created_at'], 'ix_audit_tenant_target_created'); + $table->index(['tenant_id', 'actor_id', 'created_at'], 'ix_audit_tenant_actor_created'); + }); + } + + public function down(): void + { + Schema::dropIfExists('audit_logs'); + } +}; diff --git a/routes/api.php b/routes/api.php index a17e480..fd09d28 100644 --- a/routes/api.php +++ b/routes/api.php @@ -34,6 +34,7 @@ use App\Http\Controllers\Api\V1\Design\DesignModelController as DesignModelController; use App\Http\Controllers\Api\V1\Design\ModelVersionController as DesignModelVersionController; use App\Http\Controllers\Api\V1\Design\BomTemplateController as DesignBomTemplateController; +use App\Http\Controllers\Api\V1\Design\AuditLogController as DesignAuditLogController; // error test Route::get('/test-error', function () { @@ -356,6 +357,9 @@ Route::put ('/bom-templates/{templateId}/items', [DesignBomTemplateController::class, 'replaceItems'])->name('v1.design.bom.templates.items.replace'); Route::get ('/bom-templates/{templateId}/diff', [DesignBomTemplateController::class, 'diff'])->name('v1.design.bom.templates.diff'); Route::post ('/bom-templates/{templateId}/clone', [DesignBomTemplateController::class, 'cloneTemplate'])->name('v1.design.bom.templates.clone'); + + // 감사 로그 조회 + Route::get ('/audit-logs', [DesignAuditLogController::class, 'index'])->name('v1.design.audit-logs.index'); });