From 8d492478036e3340b8814c6e3278e96214a72d95 Mon Sep 17 00:00:00 2001 From: taekyoungc Date: Thu, 25 Jun 2026 17:13:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9E=91?= =?UTF-8?q?=EC=97=85(=EA=B0=90=EC=82=AC)=20=EB=A1=9C=EA=B7=B8=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EA=B8=B0=EB=A1=9D=20+=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=A1=B0=ED=9A=8C=20=ED=99=94=EB=A9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auditable 트레잇: 모델 insert/update/delete 시 activity_log 자동 기록 (조회 제외, before/after JSON, 민감필드 마스킹, 사용자/IP) - 도메인 모델 28개에 적용 (로그성 테이블 제외) - 관리자 전용 조회: Admin\ActivityLog (기간/작업/대상/사용자 필터 + 상세 변경내역) - 라우트 admin/access/activity-logs(+상세), 메뉴 '활동 로그' 추가 - 테넌트 분리: 슈퍼=전체, 지자체관리자=소속 지자체만 Co-Authored-By: Claude Opus 4.8 --- app/Config/Routes.php | 2 + app/Controllers/Admin/ActivityLog.php | 164 ++++++++++++++++++ app/Models/BagInventoryModel.php | 2 + app/Models/BagIssueItemCodeModel.php | 2 + app/Models/BagIssueModel.php | 2 + app/Models/BagOrderItemModel.php | 2 + app/Models/BagOrderModel.php | 2 + app/Models/BagPriceHistoryModel.php | 2 + app/Models/BagPriceModel.php | 2 + app/Models/BagReceivingModel.php | 2 + app/Models/BagSaleModel.php | 2 + app/Models/BlockchainLedgerModel.php | 2 + app/Models/CodeDetailModel.php | 2 + app/Models/CodeKindModel.php | 2 + app/Models/CompanyModel.php | 2 + app/Models/DesignatedShopModel.php | 2 + app/Models/FeedbackFileModel.php | 2 + app/Models/FeedbackModel.php | 2 + app/Models/FreeRecipientModel.php | 2 + app/Models/LocalGovernmentModel.php | 2 + app/Models/ManagerModel.php | 2 + app/Models/MemberApprovalRequestModel.php | 2 + app/Models/MemberModel.php | 2 + app/Models/MenuModel.php | 2 + app/Models/MenuTypeModel.php | 2 + app/Models/PackagingUnitHistoryModel.php | 2 + app/Models/PackagingUnitModel.php | 2 + app/Models/SalesAgencyModel.php | 2 + app/Models/ShopOrderItemModel.php | 2 + app/Models/ShopOrderModel.php | 2 + app/Models/Traits/Auditable.php | 148 ++++++++++++++++ app/Views/admin/access/activity_log.php | 94 ++++++++++ .../admin/access/activity_log_detail.php | 69 ++++++++ app/Views/admin/layout.php | 2 +- writable/database/activity_log_menu_add.sql | 11 ++ 35 files changed, 545 insertions(+), 1 deletion(-) create mode 100644 app/Controllers/Admin/ActivityLog.php create mode 100644 app/Models/Traits/Auditable.php create mode 100644 app/Views/admin/access/activity_log.php create mode 100644 app/Views/admin/access/activity_log_detail.php create mode 100644 writable/database/activity_log_menu_add.sql diff --git a/app/Config/Routes.php b/app/Config/Routes.php index d8c2a93..8fbb16e 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -256,6 +256,8 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo $routes->post('users/unlock-login/(:num)', 'Admin\User::unlockLogin/$1'); $routes->post('users/delete/(:num)', 'Admin\User::delete/$1'); $routes->get('access/login-history', 'Admin\Access::loginHistory'); + $routes->get('access/activity-logs', 'Admin\ActivityLog::index'); + $routes->get('access/activity-logs/(:num)', 'Admin\ActivityLog::show/$1'); $routes->get('access/approvals', 'Admin\Access::approvals'); $routes->post('access/approve/(:num)', 'Admin\Access::approve/$1'); $routes->post('access/reject/(:num)', 'Admin\Access::reject/$1'); diff --git a/app/Controllers/Admin/ActivityLog.php b/app/Controllers/Admin/ActivityLog.php new file mode 100644 index 0000000..de1f49d --- /dev/null +++ b/app/Controllers/Admin/ActivityLog.php @@ -0,0 +1,164 @@ + '회원', + 'member_approval_request' => '회원 승인요청', + 'company' => '업체', + 'designated_shop' => '지정판매소', + 'sales_agency' => '판매 대행소', + 'free_recipient' => '무료 수령처', + 'manager' => '담당자', + 'local_government' => '지자체', + 'code_kind' => '기본코드 종류', + 'code_detail' => '기본코드', + 'packaging_unit' => '포장 단위', + 'packaging_unit_history' => '포장 단위 이력', + 'bag_price' => '단가', + 'bag_price_history' => '단가 이력', + 'bag_order' => '발주', + 'bag_order_item' => '발주 품목', + 'bag_receiving' => '입고', + 'bag_inventory' => '재고', + 'bag_sale' => '판매', + 'bag_issue' => '무료용 불출', + 'bag_issue_item_code' => '불출 품목코드', + 'shop_order' => '전화 주문', + 'shop_order_item' => '전화 주문 품목', + 'menu' => '메뉴', + 'menu_type' => '메뉴 유형', + 'feedback' => '사용자 의견', + 'feedback_file' => '의견 첨부', + 'blockchain_ledger' => '원장', + ]; + + /** action → 한글 라벨 */ + private const ACTION_LABELS = [ + 'create' => '등록', + 'update' => '수정', + 'delete' => '삭제', + ]; + + public function __construct() + { + $this->logModel = model(ActivityLogModel::class); + } + + public function index(): string + { + helper('admin'); + + $start = (string) ($this->request->getGet('start') ?? ''); + $end = (string) ($this->request->getGet('end') ?? ''); + $action = (string) ($this->request->getGet('action') ?? ''); + $table = (string) ($this->request->getGet('table') ?? ''); + $user = trim((string) ($this->request->getGet('user') ?? '')); + + $builder = $this->logModel + ->select('activity_log.*, member.mb_id, member.mb_name, member.mb_lg_idx') + ->join('member', 'member.mb_idx = activity_log.al_mb_idx', 'left'); + + // 테넌트 분리: 슈퍼/본부는 전체, 그 외 관리자는 소속 지자체 사용자만 + $level = (int) session()->get('mb_level'); + if (! Roles::isSuperAdminEquivalent($level)) { + $lgIdx = admin_effective_lg_idx(); + $builder->where('member.mb_lg_idx', $lgIdx); + } + + if ($start !== '') { + $builder->where('activity_log.al_regdate >=', $start . ' 00:00:00'); + } + if ($end !== '') { + $builder->where('activity_log.al_regdate <=', $end . ' 23:59:59'); + } + if (isset(self::ACTION_LABELS[$action])) { + $builder->where('activity_log.al_action', $action); + } + if ($table !== '' && isset(self::TABLE_LABELS[$table])) { + $builder->where('activity_log.al_table', $table); + } + if ($user !== '') { + $builder->groupStart() + ->like('member.mb_id', $user) + ->orLike('member.mb_name', $user) + ->groupEnd(); + } + + $list = $builder->orderBy('activity_log.al_idx', 'DESC')->paginate(20); + $pager = $this->logModel->pager; + + return view('admin/layout', [ + 'title' => '활동 로그', + 'content' => view('admin/access/activity_log', [ + 'list' => $list, + 'pager' => $pager, + 'start' => $start, + 'end' => $end, + 'action' => $action, + 'table' => $table, + 'user' => $user, + 'tableLabels' => self::TABLE_LABELS, + 'actionLabels' => self::ACTION_LABELS, + ]), + ]); + } + + public function show(int $id): string + { + helper('admin'); + $row = $this->logModel + ->select('activity_log.*, member.mb_id, member.mb_name, member.mb_lg_idx') + ->join('member', 'member.mb_idx = activity_log.al_mb_idx', 'left') + ->where('activity_log.al_idx', $id) + ->first(); + + if ($row === null) { + return view('admin/layout', [ + 'title' => '활동 로그', + 'content' => '
로그를 찾을 수 없습니다.
', + ]); + } + + // 테넌트 분리 검증 + $level = (int) session()->get('mb_level'); + if (! Roles::isSuperAdminEquivalent($level)) { + $lgIdx = admin_effective_lg_idx(); + if ((int) ($row->mb_lg_idx ?? 0) !== (int) $lgIdx) { + return view('admin/layout', [ + 'title' => '활동 로그', + 'content' => '
접근 권한이 없습니다.
', + ]); + } + } + + $before = $row->al_data_before ? (json_decode((string) $row->al_data_before, true) ?: []) : []; + $after = $row->al_data_after ? (json_decode((string) $row->al_data_after, true) ?: []) : []; + + return view('admin/layout', [ + 'title' => '활동 로그 상세', + 'content' => view('admin/access/activity_log_detail', [ + 'row' => $row, + 'before' => $before, + 'after' => $after, + 'tableLabels' => self::TABLE_LABELS, + 'actionLabels' => self::ACTION_LABELS, + ]), + ]); + } +} diff --git a/app/Models/BagInventoryModel.php b/app/Models/BagInventoryModel.php index 7df5310..1ce74fc 100644 --- a/app/Models/BagInventoryModel.php +++ b/app/Models/BagInventoryModel.php @@ -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'; diff --git a/app/Models/BagIssueItemCodeModel.php b/app/Models/BagIssueItemCodeModel.php index 60eb0a3..27ad605 100644 --- a/app/Models/BagIssueItemCodeModel.php +++ b/app/Models/BagIssueItemCodeModel.php @@ -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'; diff --git a/app/Models/BagIssueModel.php b/app/Models/BagIssueModel.php index 855ecc9..0a9cb7f 100644 --- a/app/Models/BagIssueModel.php +++ b/app/Models/BagIssueModel.php @@ -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'; diff --git a/app/Models/BagOrderItemModel.php b/app/Models/BagOrderItemModel.php index 9a317ab..324685f 100644 --- a/app/Models/BagOrderItemModel.php +++ b/app/Models/BagOrderItemModel.php @@ -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'; diff --git a/app/Models/BagOrderModel.php b/app/Models/BagOrderModel.php index ead93ca..d4275b7 100644 --- a/app/Models/BagOrderModel.php +++ b/app/Models/BagOrderModel.php @@ -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'; diff --git a/app/Models/BagPriceHistoryModel.php b/app/Models/BagPriceHistoryModel.php index e886cac..64658c1 100644 --- a/app/Models/BagPriceHistoryModel.php +++ b/app/Models/BagPriceHistoryModel.php @@ -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'; diff --git a/app/Models/BagPriceModel.php b/app/Models/BagPriceModel.php index 4adc3fd..e005ff3 100644 --- a/app/Models/BagPriceModel.php +++ b/app/Models/BagPriceModel.php @@ -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'; diff --git a/app/Models/BagReceivingModel.php b/app/Models/BagReceivingModel.php index a21baad..d3f35ef 100644 --- a/app/Models/BagReceivingModel.php +++ b/app/Models/BagReceivingModel.php @@ -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'; diff --git a/app/Models/BagSaleModel.php b/app/Models/BagSaleModel.php index 4769681..de310b4 100644 --- a/app/Models/BagSaleModel.php +++ b/app/Models/BagSaleModel.php @@ -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'; diff --git a/app/Models/BlockchainLedgerModel.php b/app/Models/BlockchainLedgerModel.php index 887bd55..0cf6aee 100644 --- a/app/Models/BlockchainLedgerModel.php +++ b/app/Models/BlockchainLedgerModel.php @@ -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'; diff --git a/app/Models/CodeDetailModel.php b/app/Models/CodeDetailModel.php index a82413b..8945367 100644 --- a/app/Models/CodeDetailModel.php +++ b/app/Models/CodeDetailModel.php @@ -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'; diff --git a/app/Models/CodeKindModel.php b/app/Models/CodeKindModel.php index c34bb93..e3f1b4e 100644 --- a/app/Models/CodeKindModel.php +++ b/app/Models/CodeKindModel.php @@ -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'; diff --git a/app/Models/CompanyModel.php b/app/Models/CompanyModel.php index fcaec19..92bf0d7 100644 --- a/app/Models/CompanyModel.php +++ b/app/Models/CompanyModel.php @@ -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'; diff --git a/app/Models/DesignatedShopModel.php b/app/Models/DesignatedShopModel.php index f731e80..f7bcd72 100644 --- a/app/Models/DesignatedShopModel.php +++ b/app/Models/DesignatedShopModel.php @@ -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'; diff --git a/app/Models/FeedbackFileModel.php b/app/Models/FeedbackFileModel.php index b7b9215..4bae932 100644 --- a/app/Models/FeedbackFileModel.php +++ b/app/Models/FeedbackFileModel.php @@ -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'; diff --git a/app/Models/FeedbackModel.php b/app/Models/FeedbackModel.php index b03d451..36d08c9 100644 --- a/app/Models/FeedbackModel.php +++ b/app/Models/FeedbackModel.php @@ -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'; diff --git a/app/Models/FreeRecipientModel.php b/app/Models/FreeRecipientModel.php index e9c9317..0edde8b 100644 --- a/app/Models/FreeRecipientModel.php +++ b/app/Models/FreeRecipientModel.php @@ -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'; diff --git a/app/Models/LocalGovernmentModel.php b/app/Models/LocalGovernmentModel.php index 6f47ff2..5606335 100644 --- a/app/Models/LocalGovernmentModel.php +++ b/app/Models/LocalGovernmentModel.php @@ -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'; diff --git a/app/Models/ManagerModel.php b/app/Models/ManagerModel.php index e3de857..2ae9d45 100644 --- a/app/Models/ManagerModel.php +++ b/app/Models/ManagerModel.php @@ -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'; diff --git a/app/Models/MemberApprovalRequestModel.php b/app/Models/MemberApprovalRequestModel.php index 46dd0dc..d2efb8b 100644 --- a/app/Models/MemberApprovalRequestModel.php +++ b/app/Models/MemberApprovalRequestModel.php @@ -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'; diff --git a/app/Models/MemberModel.php b/app/Models/MemberModel.php index 252d838..aef5ad0 100644 --- a/app/Models/MemberModel.php +++ b/app/Models/MemberModel.php @@ -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'; diff --git a/app/Models/MenuModel.php b/app/Models/MenuModel.php index 1950f8f..0631e53 100644 --- a/app/Models/MenuModel.php +++ b/app/Models/MenuModel.php @@ -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'; diff --git a/app/Models/MenuTypeModel.php b/app/Models/MenuTypeModel.php index 36e5bac..c1c264f 100644 --- a/app/Models/MenuTypeModel.php +++ b/app/Models/MenuTypeModel.php @@ -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'; diff --git a/app/Models/PackagingUnitHistoryModel.php b/app/Models/PackagingUnitHistoryModel.php index 6206787..2e733ae 100644 --- a/app/Models/PackagingUnitHistoryModel.php +++ b/app/Models/PackagingUnitHistoryModel.php @@ -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'; diff --git a/app/Models/PackagingUnitModel.php b/app/Models/PackagingUnitModel.php index dc7317d..8044b39 100644 --- a/app/Models/PackagingUnitModel.php +++ b/app/Models/PackagingUnitModel.php @@ -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'; diff --git a/app/Models/SalesAgencyModel.php b/app/Models/SalesAgencyModel.php index 2c9b54b..639156f 100644 --- a/app/Models/SalesAgencyModel.php +++ b/app/Models/SalesAgencyModel.php @@ -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'; diff --git a/app/Models/ShopOrderItemModel.php b/app/Models/ShopOrderItemModel.php index c0ae5eb..8b11f29 100644 --- a/app/Models/ShopOrderItemModel.php +++ b/app/Models/ShopOrderItemModel.php @@ -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'; diff --git a/app/Models/ShopOrderModel.php b/app/Models/ShopOrderModel.php index cfbd7e3..d3a13b1 100644 --- a/app/Models/ShopOrderModel.php +++ b/app/Models/ShopOrderModel.php @@ -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'; diff --git a/app/Models/Traits/Auditable.php b/app/Models/Traits/Auditable.php new file mode 100644 index 0000000..9fc9e57 --- /dev/null +++ b/app/Models/Traits/Auditable.php @@ -0,0 +1,148 @@ + 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; + } +} diff --git a/app/Views/admin/access/activity_log.php b/app/Views/admin/access/activity_log.php new file mode 100644 index 0000000..f0fcd82 --- /dev/null +++ b/app/Views/admin/access/activity_log.php @@ -0,0 +1,94 @@ + $list + * @var string $start + * @var string $end + * @var string $action + * @var string $table + * @var string $user + * @var array $tableLabels + * @var array $actionLabels + */ +$actionBadge = static function (string $a): string { + $map = [ + 'create' => 'background:#dcfce7;color:#166534;', + 'update' => 'background:#dbeafe;color:#1e40af;', + 'delete' => 'background:#fee2e2;color:#991b1b;', + ]; + return $map[$a] ?? 'background:#f3f4f6;color:#374151;'; +}; +?> + '활동 로그']) ?> + +
+
+ + + ~ + + + + + + + + + + 초기화 +
+
+ +
+ + + + + + + + + + + + + + + al_action ?? ''); + $tbl = (string) ($row->al_table ?? ''); + $who = trim((string) ($row->mb_name ?? '') . (($row->mb_id ?? '') !== '' ? ' (' . $row->mb_id . ')' : '')); + ?> + + + + + + + + + + + + + + +
일시사용자작업대상대상 번호IP상세
al_regdate ?? '')) ?>시스템' ?> + + + + al_record_id ?? 0) ?: '-' ?>al_ip ?? '')) ?> + 보기 +
조회된 로그가 없습니다.
+
+
links() ?>
diff --git a/app/Views/admin/access/activity_log_detail.php b/app/Views/admin/access/activity_log_detail.php new file mode 100644 index 0000000..7388f18 --- /dev/null +++ b/app/Views/admin/access/activity_log_detail.php @@ -0,0 +1,69 @@ + $before + * @var array $after + * @var array $tableLabels + * @var array $actionLabels + */ +$act = (string) ($row->al_action ?? ''); +$tbl = (string) ($row->al_table ?? ''); +$who = trim((string) ($row->mb_name ?? '') . (($row->mb_id ?? '') !== '' ? ' (' . $row->mb_id . ')' : '')); + +// 변경된 키 강조용: before/after 값이 다른 키 +$allKeys = array_values(array_unique(array_merge(array_keys($before), array_keys($after)))); +$fmt = static function ($v): string { + if (is_array($v)) { + return json_encode($v, JSON_UNESCAPED_UNICODE); + } + return (string) ($v ?? ''); +}; +?> +
+
+ ← 목록으로 + +
+ + + + + + + + + +
일시al_regdate ?? '')) ?>
사용자
작업
대상 () · 번호 al_record_id ?? 0) ?>
IPal_ip ?? '')) ?>
+ +

변경 내역 (전 → 후)

+ +

상세 데이터가 없습니다.

+ +
+ + + + + + + + + + + + > + + + + + + +
항목변경 전변경 후
—' : esc($b) ?>—' : esc($a) ?>
+
+ +
diff --git a/app/Views/admin/layout.php b/app/Views/admin/layout.php index 7de9d0d..8a1bdd5 100644 --- a/app/Views/admin/layout.php +++ b/app/Views/admin/layout.php @@ -33,7 +33,7 @@ if ($navItems === []) { $navItems = [ ['idx' => 0, 'name' => '대시보드', 'href' => 'admin', 'url' => base_url('admin'), 'children' => [], 'hasChildren' => false], ['idx' => 0, 'name' => '회원·접근', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [ - $mk('회원 관리', 'admin/users'), $mk('로그인 이력', 'admin/access/login-history'), $mk('승인 대기', 'admin/access/approvals'), + $mk('회원 관리', 'admin/users'), $mk('로그인 이력', 'admin/access/login-history'), $mk('활동 로그', 'admin/access/activity-logs'), $mk('승인 대기', 'admin/access/approvals'), ]], ['idx' => 0, 'name' => '시스템', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [ $mk('역할', 'admin/roles'), $mk('메뉴', 'admin/menus'), diff --git a/writable/database/activity_log_menu_add.sql b/writable/database/activity_log_menu_add.sql new file mode 100644 index 0000000..c4d7509 --- /dev/null +++ b/writable/database/activity_log_menu_add.sql @@ -0,0 +1,11 @@ +-- 활동 로그 조회 메뉴 추가 (각 지자체의 '로그인 이력' 메뉴 옆에 삽입, 멱등) +INSERT INTO `menu` (`mt_idx`, `lg_idx`, `mm_name`, `mm_link`, `mm_pidx`, `mm_dep`, `mm_num`, `mm_cnode`, `mm_level`, `mm_is_view`) +SELECT lh.`mt_idx`, lh.`lg_idx`, '활동 로그', 'admin/access/activity-logs', + lh.`mm_pidx`, lh.`mm_dep`, lh.`mm_num` + 1, lh.`mm_cnode`, lh.`mm_level`, lh.`mm_is_view` +FROM `menu` lh +WHERE lh.`mm_link` = 'admin/access/login-history' + AND NOT EXISTS ( + SELECT 1 FROM `menu` x + WHERE x.`lg_idx` = lh.`lg_idx` + AND x.`mm_link` = 'admin/access/activity-logs' + );