feat: 사용자 작업(감사) 로그 자동 기록 + 관리자 조회 화면
- Auditable 트레잇: 모델 insert/update/delete 시 activity_log 자동 기록 (조회 제외, before/after JSON, 민감필드 마스킹, 사용자/IP) - 도메인 모델 28개에 적용 (로그성 테이블 제외) - 관리자 전용 조회: Admin\ActivityLog (기간/작업/대상/사용자 필터 + 상세 변경내역) - 라우트 admin/access/activity-logs(+상세), 메뉴 '활동 로그' 추가 - 테넌트 분리: 슈퍼=전체, 지자체관리자=소속 지자체만 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class BagInventoryModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'bag_inventory';
|
||||
protected $primaryKey = 'bi_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class BagIssueItemCodeModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'bag_issue_item_code';
|
||||
protected $primaryKey = 'bic_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class BagIssueModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'bag_issue';
|
||||
protected $primaryKey = 'bi2_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class BagOrderItemModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'bag_order_item';
|
||||
protected $primaryKey = 'boi_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class BagOrderModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'bag_order';
|
||||
protected $primaryKey = 'bo_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class BagPriceHistoryModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'bag_price_history';
|
||||
protected $primaryKey = 'bph_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class BagPriceModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'bag_price';
|
||||
protected $primaryKey = 'bp_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class BagReceivingModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'bag_receiving';
|
||||
protected $primaryKey = 'br_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class BagSaleModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'bag_sale';
|
||||
protected $primaryKey = 'bs_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class BlockchainLedgerModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'blockchain_ledger';
|
||||
protected $primaryKey = 'bl_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -8,6 +8,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class CodeDetailModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'code_detail';
|
||||
protected $primaryKey = 'cd_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class CodeKindModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'code_kind';
|
||||
protected $primaryKey = 'ck_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class CompanyModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'company';
|
||||
protected $primaryKey = 'cp_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class DesignatedShopModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'designated_shop';
|
||||
protected $primaryKey = 'ds_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -11,6 +11,8 @@ use CodeIgniter\Model;
|
||||
*/
|
||||
class FeedbackFileModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'feedback_file';
|
||||
protected $primaryKey = 'ff_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -11,6 +11,8 @@ use CodeIgniter\Model;
|
||||
*/
|
||||
class FeedbackModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'feedback';
|
||||
protected $primaryKey = 'fb_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class FreeRecipientModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'free_recipient';
|
||||
protected $primaryKey = 'fr_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class LocalGovernmentModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'local_government';
|
||||
protected $primaryKey = 'lg_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class ManagerModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'manager';
|
||||
protected $primaryKey = 'mg_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class MemberApprovalRequestModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class MemberModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'member';
|
||||
protected $primaryKey = 'mb_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class MenuModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'menu';
|
||||
protected $primaryKey = 'mm_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class MenuTypeModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'menu_type';
|
||||
protected $primaryKey = 'mt_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class PackagingUnitHistoryModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'packaging_unit_history';
|
||||
protected $primaryKey = 'puh_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class PackagingUnitModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'packaging_unit';
|
||||
protected $primaryKey = 'pu_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -8,6 +8,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class SalesAgencyModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'sales_agency';
|
||||
protected $primaryKey = 'sa_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class ShopOrderItemModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'shop_order_item';
|
||||
protected $primaryKey = 'soi_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class ShopOrderModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'shop_order';
|
||||
protected $primaryKey = 'so_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
148
app/Models/Traits/Auditable.php
Normal file
148
app/Models/Traits/Auditable.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?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'];
|
||||
|
||||
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 ($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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user