Files
sam-api/app/Traits/Auditable.php
권혁성 189b38c936 feat: Auditable 트레이트 구현 및 97개 모델 적용
- Auditable 트레이트 신규 생성 (bootAuditable 패턴)
  - creating: created_by/updated_by 자동 채우기
  - updating: updated_by 자동 채우기
  - deleting: deleted_by 채우기 + saveQuietly()
  - created/updated/deleted: audit_logs 자동 기록
- 기존 AuditLogger 패턴과 동일한 try/catch 조용한 실패
- 변경된 필드만 before/after 기록 (updated 이벤트)
- auditExclude 프로퍼티로 모델별 제외 필드 설정 가능
- 제외 대상: Attendance, StockTransaction, TodayIssue 등 고빈도/시스템 모델

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:33:54 +09:00

125 lines
3.6 KiB
PHP

<?php
namespace App\Traits;
use App\Models\Audit\AuditLog;
use Illuminate\Support\Str;
trait Auditable
{
protected static function bootAuditable(): void
{
static::creating(function ($model) {
$actorId = static::resolveActorId();
if ($actorId) {
if ($model->isFillable('created_by') && ! $model->created_by) {
$model->created_by = $actorId;
}
if ($model->isFillable('updated_by') && ! $model->updated_by) {
$model->updated_by = $actorId;
}
}
});
static::updating(function ($model) {
$actorId = static::resolveActorId();
if ($actorId && $model->isFillable('updated_by')) {
$model->updated_by = $actorId;
}
});
static::deleting(function ($model) {
$actorId = static::resolveActorId();
if ($actorId && $model->isFillable('deleted_by')) {
$model->deleted_by = $actorId;
$model->saveQuietly();
}
});
static::created(function ($model) {
$model->logAuditEvent('created', null, $model->toAuditSnapshot());
});
static::updated(function ($model) {
$dirty = $model->getChanges();
$excluded = $model->getAuditExcludedFields();
$changed = array_diff_key($dirty, array_flip($excluded));
if (empty($changed)) {
return;
}
$before = [];
$after = [];
foreach ($changed as $key => $value) {
$before[$key] = $model->getOriginal($key);
$after[$key] = $value;
}
$model->logAuditEvent('updated', $before, $after);
});
static::deleted(function ($model) {
$model->logAuditEvent('deleted', $model->toAuditSnapshot(), null);
});
}
public function getAuditExcludedFields(): array
{
$defaults = [
'created_at', 'updated_at', 'deleted_at',
'created_by', 'updated_by', 'deleted_by',
];
$custom = property_exists($this, 'auditExclude') ? $this->auditExclude : [];
return array_merge($defaults, $custom);
}
public function getAuditTargetType(): string
{
$className = class_basename(static::class);
return Str::snake($className);
}
protected function toAuditSnapshot(): array
{
$excluded = $this->getAuditExcludedFields();
return array_diff_key($this->attributesToArray(), array_flip($excluded));
}
protected function logAuditEvent(string $action, ?array $before, ?array $after): void
{
try {
$tenantId = $this->tenant_id ?? null;
if (! $tenantId) {
return;
}
$request = request();
AuditLog::create([
'tenant_id' => $tenantId,
'target_type' => $this->getAuditTargetType(),
'target_id' => $this->getKey(),
'action' => $action,
'before' => $before,
'after' => $after,
'actor_id' => static::resolveActorId(),
'ip' => $request?->ip(),
'ua' => $request?->userAgent(),
'created_at' => now(),
]);
} catch (\Throwable $e) {
// 감사 로그 실패는 업무 흐름을 방해하지 않음
}
}
protected static function resolveActorId(): ?int
{
return auth()->id();
}
}