feat: 통합 감사 로그 도입 및 조회 API/스케줄러 추가

- 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 제거)
This commit is contained in:
2025-09-11 14:39:55 +09:00
parent 17fa82c35b
commit 785e367472
13 changed files with 404 additions and 1 deletions

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Console\Commands;
use App\Models\Audit\AuditLog;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
class PruneAuditLogs extends Command
{
protected $signature = 'audit:prune {--days=}';
protected $description = 'Delete audit logs older than given days (default: config(audit.retention_days)).';
public function handle(): int
{
$days = (int) ($this->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;
}
}

26
app/Console/Kernel.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
namespace App\Console;
use App\Console\Commands\PruneAuditLogs;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
protected $commands = [
PruneAuditLogs::class,
];
protected function schedule(Schedule $schedule): void
{
// 매일 새벽 03:10에 감사 로그 정리(환경값 기반 보관기간)
$schedule->command('audit:prune')->dailyAt('03:10');
}
protected function commands(): void
{
// 라우트 콘솔 혹은 커맨드 자동 로딩 필요 시 사용
// $this->load(__DIR__.'/Commands');
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Api\V1\Design;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Audit\AuditLogIndexRequest;
use App\Services\Audit\AuditLogService;
class AuditLogController extends Controller
{
public function __construct(
protected AuditLogService $service
) {}
public function index(AuditLogIndexRequest $request)
{
return ApiResponse::handle(function () use ($request) {
$filters = $request->validated();
return $this->service->paginate($filters);
}, __('message.fetched'));
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\Audit;
use Illuminate\Foundation\Http\FormRequest;
class AuditLogIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'page' => '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',
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Models\Audit;
use Illuminate\Database\Eloquent\Model;
class AuditLog extends Model
{
public $timestamps = false;
protected $table = 'audit_logs';
protected $fillable = [
'tenant_id','target_type','target_id','action','before','after','actor_id','ip','ua','created_at',
];
protected $casts = [
'before' => 'array',
'after' => 'array',
'created_at' => 'datetime',
];
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Services\Audit;
use App\Models\Audit\AuditLog;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class AuditLogService extends Service
{
public function paginate(array $filters): LengthAwarePaginator
{
$tenantId = $this->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);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Services\Audit;
use App\Models\Audit\AuditLog;
class AuditLogger
{
/**
* 감사 로그 기록
*/
public function log(
int $tenantId,
string $targetType,
?int $targetId,
string $action,
?array $before = null,
?array $after = null
): void {
try {
$request = request();
$actorId = optional(auth()->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) {
// 감사 로그 실패는 업무 흐름을 방해하지 않음
}
}
}

View File

@@ -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;
});
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(
* name="Design Audit",
* description="Design-time audit logs"
* )
*/
class AuditLogApi
{
/**
* @OA\Get(
* path="/api/v1/design/audit-logs",
* tags={"Design Audit"},
* summary="List audit logs",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
* @OA\Parameter(name="page", in="query", @OA\Schema(type="integer", minimum=1)),
* @OA\Parameter(name="size", in="query", @OA\Schema(type="integer", minimum=1, maximum=200)),
* @OA\Parameter(name="target_type", in="query", @OA\Schema(type="string")),
* @OA\Parameter(name="target_id", in="query", @OA\Schema(type="integer")),
* @OA\Parameter(name="action", in="query", @OA\Schema(type="string")),
* @OA\Parameter(name="actor_id", in="query", @OA\Schema(type="integer")),
* @OA\Parameter(name="from", in="query", @OA\Schema(type="string", format="date-time")),
* @OA\Parameter(name="to", in="query", @OA\Schema(type="string", format="date-time")),
* @OA\Response(
* response=200,
* description="List",
* @OA\JsonContent(
* type="object",
* @OA\Property(property="success", type="boolean"),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="current_page", type="integer"),
* @OA\Property(property="data", type="array", @OA\Items(type="object",
* @OA\Property(property="tenant_id", type="integer"),
* @OA\Property(property="target_type", type="string"),
* @OA\Property(property="target_id", type="integer", nullable=true),
* @OA\Property(property="action", type="string"),
* @OA\Property(property="before", type="object", nullable=true),
* @OA\Property(property="after", type="object", nullable=true),
* @OA\Property(property="actor_id", type="integer", nullable=true),
* @OA\Property(property="ip", type="string", nullable=true),
* @OA\Property(property="ua", type="string", nullable=true),
* @OA\Property(property="created_at", type="string", format="date-time")
* ))
* )
* )
* )
* )
*/
public function docs() {}
}

9
config/audit.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
return [
// 감사 로그 보관기간(일)
'retention_days' => env('AUDIT_RETENTION_DAYS', 395), // 기본 13개월
// 조회(diff) 로그 기록 여부(기본 비활성). 개발/QA에서만 활성화 권장
'log_reads' => env('AUDIT_LOG_READS', false),
];

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('audit_logs', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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');
});