페이지 접속·로그인 시 세션토큰/접속시각 갱신이 '로그인'으로 잔뜩 기록되던 노이즈 제거. Auditable에 auditSkipUpdateOnlyFields 추가, MemberModel에서 세션/로그인 필드 지정. (로그인 자체는 '로그인 이력'에서 별도 확인) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
166 lines
5.5 KiB
PHP
166 lines
5.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Models\Traits;
|
|
|
|
/**
|
|
* 모델 CRUD(생성/수정/삭제) 자동 감사 로그 트레잇.
|
|
*
|
|
* 모델에 `use \App\Models\Traits\Auditable;` 만 추가하면
|
|
* insert/update/delete 시 activity_log 에 "누가·무엇을·언제" 자동 기록한다.
|
|
* (조회(select)는 기록하지 않음)
|
|
*
|
|
* - 변경 전(before)/후(after) 데이터를 JSON 으로 보관
|
|
* - 비밀번호·OTP 비밀키·이메일·전화 등 민감 필드는 마스킹
|
|
* - 기록 실패는 audit_log() 내부에서 흡수되어 본 로직에 영향 없음
|
|
*/
|
|
trait Auditable
|
|
{
|
|
/** 변경/삭제 전 원본 보관 (PK => row) */
|
|
protected array $auditBeforeRows = [];
|
|
|
|
/**
|
|
* 모델 이벤트(콜백) 등록.
|
|
* BaseModel 이 이미 $afterInsert 등 속성을 선언하므로(속성 충돌 방지),
|
|
* 트레잇에서는 속성 대신 initialize()에서 런타임에 콜백을 덧붙인다.
|
|
*/
|
|
protected function initialize()
|
|
{
|
|
$this->allowCallbacks = true;
|
|
$this->afterInsert[] = 'auditAfterInsert';
|
|
$this->beforeUpdate[] = 'auditBeforeUpdate';
|
|
$this->afterUpdate[] = 'auditAfterUpdate';
|
|
$this->beforeDelete[] = 'auditBeforeDelete';
|
|
$this->afterDelete[] = 'auditAfterDelete';
|
|
}
|
|
|
|
/** 마스킹 대상 필드명 */
|
|
protected array $auditMaskFields = ['mb_passwd', 'mb_totp_secret', 'mb_session_token', 'mb_email', 'mb_phone'];
|
|
|
|
/**
|
|
* 이 필드들'만' 변경된 update는 활동 로그를 남기지 않는다.
|
|
* (예: 로그인/세션 갱신 같은 시스템 내부 변경 — 노이즈 제거)
|
|
* 모델에서 재정의해 사용.
|
|
*/
|
|
protected array $auditSkipUpdateOnlyFields = [];
|
|
|
|
private function auditMask(?array $row): ?array
|
|
{
|
|
if ($row === null) {
|
|
return null;
|
|
}
|
|
foreach ($this->auditMaskFields as $f) {
|
|
if (array_key_exists($f, $row) && (string) $row[$f] !== '') {
|
|
$row[$f] = '***';
|
|
}
|
|
}
|
|
|
|
return $row;
|
|
}
|
|
|
|
/** find 콜백 재귀를 피하려고 쿼리빌더로 원본 행을 직접 조회 */
|
|
private function auditFetchRaw(int|string $id): ?array
|
|
{
|
|
try {
|
|
$row = $this->db->table($this->table)
|
|
->where($this->primaryKey, $id)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
return $row ?: null;
|
|
} catch (\Throwable $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public function auditAfterInsert(array $eventData): array
|
|
{
|
|
helper('audit');
|
|
$after = is_array($eventData['data'] ?? null) ? $eventData['data'] : null;
|
|
audit_log('create', $this->table, (int) ($eventData['id'] ?? 0), null, $this->auditMask($after));
|
|
|
|
return $eventData;
|
|
}
|
|
|
|
public function auditBeforeUpdate(array $eventData): array
|
|
{
|
|
$this->auditBeforeRows = [];
|
|
$ids = $eventData['id'] ?? null;
|
|
if ($ids !== null) {
|
|
foreach ((array) $ids as $one) {
|
|
$row = $this->auditFetchRaw($one);
|
|
if ($row !== null) {
|
|
$this->auditBeforeRows[(string) $one] = $row;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $eventData;
|
|
}
|
|
|
|
public function auditAfterUpdate(array $eventData): array
|
|
{
|
|
helper('audit');
|
|
$ids = $eventData['id'] ?? null;
|
|
$changes = is_array($eventData['data'] ?? null) ? $eventData['data'] : [];
|
|
|
|
// 지정 필드만 변경된 경우(로그인/세션 갱신 등) → 기록 생략
|
|
if ($this->auditSkipUpdateOnlyFields !== [] && $changes !== []) {
|
|
$changedKeys = array_values(array_diff(array_keys($changes), [$this->primaryKey]));
|
|
if ($changedKeys !== [] && array_diff($changedKeys, $this->auditSkipUpdateOnlyFields) === []) {
|
|
$this->auditBeforeRows = [];
|
|
|
|
return $eventData;
|
|
}
|
|
}
|
|
|
|
if ($ids !== null) {
|
|
foreach ((array) $ids as $one) {
|
|
$before = $this->auditBeforeRows[(string) $one] ?? null;
|
|
$after = $before !== null ? array_merge($before, $changes) : $changes;
|
|
audit_log('update', $this->table, (int) $one, $this->auditMask($before), $this->auditMask($after));
|
|
}
|
|
} else {
|
|
// 조건(where) 기반 일괄 수정: 대상 PK 미상 → 변경값만 기록
|
|
audit_log('update', $this->table, 0, null, $this->auditMask($changes));
|
|
}
|
|
$this->auditBeforeRows = [];
|
|
|
|
return $eventData;
|
|
}
|
|
|
|
public function auditBeforeDelete(array $eventData): array
|
|
{
|
|
$this->auditBeforeRows = [];
|
|
$ids = $eventData['id'] ?? null;
|
|
if ($ids !== null) {
|
|
foreach ((array) $ids as $one) {
|
|
$row = $this->auditFetchRaw($one);
|
|
if ($row !== null) {
|
|
$this->auditBeforeRows[(string) $one] = $row;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $eventData;
|
|
}
|
|
|
|
public function auditAfterDelete(array $eventData): array
|
|
{
|
|
helper('audit');
|
|
$ids = $eventData['id'] ?? null;
|
|
if ($ids !== null) {
|
|
foreach ((array) $ids as $one) {
|
|
$before = $this->auditBeforeRows[(string) $one] ?? null;
|
|
audit_log('delete', $this->table, (int) $one, $this->auditMask($before), null);
|
|
}
|
|
} else {
|
|
audit_log('delete', $this->table, 0, null, null);
|
|
}
|
|
$this->auditBeforeRows = [];
|
|
|
|
return $eventData;
|
|
}
|
|
}
|