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:
@@ -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/unlock-login/(:num)', 'Admin\User::unlockLogin/$1');
|
||||||
$routes->post('users/delete/(:num)', 'Admin\User::delete/$1');
|
$routes->post('users/delete/(:num)', 'Admin\User::delete/$1');
|
||||||
$routes->get('access/login-history', 'Admin\Access::loginHistory');
|
$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->get('access/approvals', 'Admin\Access::approvals');
|
||||||
$routes->post('access/approve/(:num)', 'Admin\Access::approve/$1');
|
$routes->post('access/approve/(:num)', 'Admin\Access::approve/$1');
|
||||||
$routes->post('access/reject/(:num)', 'Admin\Access::reject/$1');
|
$routes->post('access/reject/(:num)', 'Admin\Access::reject/$1');
|
||||||
|
|||||||
164
app/Controllers/Admin/ActivityLog.php
Normal file
164
app/Controllers/Admin/ActivityLog.php
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Controllers\BaseController;
|
||||||
|
use App\Models\ActivityLogModel;
|
||||||
|
use Config\Roles;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 작업(감사) 로그 조회 — 생성/수정/삭제 이력.
|
||||||
|
* 관리자 전용(adminAuth). 슈퍼/본부는 전체, 지자체관리자는 소속 지자체 사용자만.
|
||||||
|
*/
|
||||||
|
class ActivityLog extends BaseController
|
||||||
|
{
|
||||||
|
private ActivityLogModel $logModel;
|
||||||
|
|
||||||
|
/** 테이블명 → 한글 라벨 */
|
||||||
|
private const TABLE_LABELS = [
|
||||||
|
'member' => '회원',
|
||||||
|
'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' => '<div class="p-6 text-gray-500">로그를 찾을 수 없습니다.</div>',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테넌트 분리 검증
|
||||||
|
$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' => '<div class="p-6 text-gray-500">접근 권한이 없습니다.</div>',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class BagInventoryModel extends Model
|
class BagInventoryModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'bag_inventory';
|
protected $table = 'bag_inventory';
|
||||||
protected $primaryKey = 'bi_idx';
|
protected $primaryKey = 'bi_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class BagIssueItemCodeModel extends Model
|
class BagIssueItemCodeModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'bag_issue_item_code';
|
protected $table = 'bag_issue_item_code';
|
||||||
protected $primaryKey = 'bic_idx';
|
protected $primaryKey = 'bic_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class BagIssueModel extends Model
|
class BagIssueModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'bag_issue';
|
protected $table = 'bag_issue';
|
||||||
protected $primaryKey = 'bi2_idx';
|
protected $primaryKey = 'bi2_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class BagOrderItemModel extends Model
|
class BagOrderItemModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'bag_order_item';
|
protected $table = 'bag_order_item';
|
||||||
protected $primaryKey = 'boi_idx';
|
protected $primaryKey = 'boi_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class BagOrderModel extends Model
|
class BagOrderModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'bag_order';
|
protected $table = 'bag_order';
|
||||||
protected $primaryKey = 'bo_idx';
|
protected $primaryKey = 'bo_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class BagPriceHistoryModel extends Model
|
class BagPriceHistoryModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'bag_price_history';
|
protected $table = 'bag_price_history';
|
||||||
protected $primaryKey = 'bph_idx';
|
protected $primaryKey = 'bph_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class BagPriceModel extends Model
|
class BagPriceModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'bag_price';
|
protected $table = 'bag_price';
|
||||||
protected $primaryKey = 'bp_idx';
|
protected $primaryKey = 'bp_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class BagReceivingModel extends Model
|
class BagReceivingModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'bag_receiving';
|
protected $table = 'bag_receiving';
|
||||||
protected $primaryKey = 'br_idx';
|
protected $primaryKey = 'br_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class BagSaleModel extends Model
|
class BagSaleModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'bag_sale';
|
protected $table = 'bag_sale';
|
||||||
protected $primaryKey = 'bs_idx';
|
protected $primaryKey = 'bs_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class BlockchainLedgerModel extends Model
|
class BlockchainLedgerModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'blockchain_ledger';
|
protected $table = 'blockchain_ledger';
|
||||||
protected $primaryKey = 'bl_idx';
|
protected $primaryKey = 'bl_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class CodeDetailModel extends Model
|
class CodeDetailModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'code_detail';
|
protected $table = 'code_detail';
|
||||||
protected $primaryKey = 'cd_idx';
|
protected $primaryKey = 'cd_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class CodeKindModel extends Model
|
class CodeKindModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'code_kind';
|
protected $table = 'code_kind';
|
||||||
protected $primaryKey = 'ck_idx';
|
protected $primaryKey = 'ck_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class CompanyModel extends Model
|
class CompanyModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'company';
|
protected $table = 'company';
|
||||||
protected $primaryKey = 'cp_idx';
|
protected $primaryKey = 'cp_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class DesignatedShopModel extends Model
|
class DesignatedShopModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'designated_shop';
|
protected $table = 'designated_shop';
|
||||||
protected $primaryKey = 'ds_idx';
|
protected $primaryKey = 'ds_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ use CodeIgniter\Model;
|
|||||||
*/
|
*/
|
||||||
class FeedbackFileModel extends Model
|
class FeedbackFileModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'feedback_file';
|
protected $table = 'feedback_file';
|
||||||
protected $primaryKey = 'ff_idx';
|
protected $primaryKey = 'ff_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ use CodeIgniter\Model;
|
|||||||
*/
|
*/
|
||||||
class FeedbackModel extends Model
|
class FeedbackModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'feedback';
|
protected $table = 'feedback';
|
||||||
protected $primaryKey = 'fb_idx';
|
protected $primaryKey = 'fb_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class FreeRecipientModel extends Model
|
class FreeRecipientModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'free_recipient';
|
protected $table = 'free_recipient';
|
||||||
protected $primaryKey = 'fr_idx';
|
protected $primaryKey = 'fr_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class LocalGovernmentModel extends Model
|
class LocalGovernmentModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'local_government';
|
protected $table = 'local_government';
|
||||||
protected $primaryKey = 'lg_idx';
|
protected $primaryKey = 'lg_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class ManagerModel extends Model
|
class ManagerModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'manager';
|
protected $table = 'manager';
|
||||||
protected $primaryKey = 'mg_idx';
|
protected $primaryKey = 'mg_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class MemberApprovalRequestModel extends Model
|
class MemberApprovalRequestModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
public const STATUS_PENDING = 'pending';
|
public const STATUS_PENDING = 'pending';
|
||||||
public const STATUS_APPROVED = 'approved';
|
public const STATUS_APPROVED = 'approved';
|
||||||
public const STATUS_REJECTED = 'rejected';
|
public const STATUS_REJECTED = 'rejected';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class MemberModel extends Model
|
class MemberModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'member';
|
protected $table = 'member';
|
||||||
protected $primaryKey = 'mb_idx';
|
protected $primaryKey = 'mb_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class MenuModel extends Model
|
class MenuModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'menu';
|
protected $table = 'menu';
|
||||||
protected $primaryKey = 'mm_idx';
|
protected $primaryKey = 'mm_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class MenuTypeModel extends Model
|
class MenuTypeModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'menu_type';
|
protected $table = 'menu_type';
|
||||||
protected $primaryKey = 'mt_idx';
|
protected $primaryKey = 'mt_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class PackagingUnitHistoryModel extends Model
|
class PackagingUnitHistoryModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'packaging_unit_history';
|
protected $table = 'packaging_unit_history';
|
||||||
protected $primaryKey = 'puh_idx';
|
protected $primaryKey = 'puh_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class PackagingUnitModel extends Model
|
class PackagingUnitModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'packaging_unit';
|
protected $table = 'packaging_unit';
|
||||||
protected $primaryKey = 'pu_idx';
|
protected $primaryKey = 'pu_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class SalesAgencyModel extends Model
|
class SalesAgencyModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'sales_agency';
|
protected $table = 'sales_agency';
|
||||||
protected $primaryKey = 'sa_idx';
|
protected $primaryKey = 'sa_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class ShopOrderItemModel extends Model
|
class ShopOrderItemModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'shop_order_item';
|
protected $table = 'shop_order_item';
|
||||||
protected $primaryKey = 'soi_idx';
|
protected $primaryKey = 'soi_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class ShopOrderModel extends Model
|
class ShopOrderModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'shop_order';
|
protected $table = 'shop_order';
|
||||||
protected $primaryKey = 'so_idx';
|
protected $primaryKey = 'so_idx';
|
||||||
protected $returnType = 'object';
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
app/Views/admin/access/activity_log.php
Normal file
94
app/Views/admin/access/activity_log.php
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* @var list<object> $list
|
||||||
|
* @var string $start
|
||||||
|
* @var string $end
|
||||||
|
* @var string $action
|
||||||
|
* @var string $table
|
||||||
|
* @var string $user
|
||||||
|
* @var array<string,string> $tableLabels
|
||||||
|
* @var array<string,string> $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;';
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<?= view('components/print_header', ['printTitle' => '활동 로그']) ?>
|
||||||
|
|
||||||
|
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
|
||||||
|
<form method="GET" action="<?= base_url('admin/access/activity-logs') ?>" class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<label class="font-bold text-gray-700">조회기간</label>
|
||||||
|
<input type="date" name="start" class="border border-gray-300 rounded px-2 py-1 text-sm" value="<?= esc($start) ?>"/>
|
||||||
|
<span>~</span>
|
||||||
|
<input type="date" name="end" class="border border-gray-300 rounded px-2 py-1 text-sm" value="<?= esc($end) ?>"/>
|
||||||
|
|
||||||
|
<select name="action" class="border border-gray-300 rounded px-2 py-1 text-sm">
|
||||||
|
<option value="">작업 전체</option>
|
||||||
|
<?php foreach ($actionLabels as $k => $lbl): ?>
|
||||||
|
<option value="<?= esc($k, 'attr') ?>" <?= $action === $k ? 'selected' : '' ?>><?= esc($lbl) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select name="table" class="border border-gray-300 rounded px-2 py-1 text-sm max-w-[12rem]">
|
||||||
|
<option value="">대상 전체</option>
|
||||||
|
<?php foreach ($tableLabels as $k => $lbl): ?>
|
||||||
|
<option value="<?= esc($k, 'attr') ?>" <?= $table === $k ? 'selected' : '' ?>><?= esc($lbl) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input type="text" name="user" placeholder="사용자(아이디/이름)" class="border border-gray-300 rounded px-2 py-1 text-sm" value="<?= esc($user) ?>"/>
|
||||||
|
|
||||||
|
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition border border-transparent">조회</button>
|
||||||
|
<a href="<?= base_url('admin/access/activity-logs') ?>" class="border border-gray-300 text-gray-600 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50">초기화</a>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="border border-gray-300 rounded-lg p-4 overflow-auto mt-2">
|
||||||
|
<table class="w-full data-table text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-left">일시</th>
|
||||||
|
<th class="text-left">사용자</th>
|
||||||
|
<th class="text-center">작업</th>
|
||||||
|
<th class="text-left">대상</th>
|
||||||
|
<th class="text-center">대상 번호</th>
|
||||||
|
<th class="text-left">IP</th>
|
||||||
|
<th class="text-center no-print">상세</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($list as $row): ?>
|
||||||
|
<?php
|
||||||
|
$act = (string) ($row->al_action ?? '');
|
||||||
|
$tbl = (string) ($row->al_table ?? '');
|
||||||
|
$who = trim((string) ($row->mb_name ?? '') . (($row->mb_id ?? '') !== '' ? ' (' . $row->mb_id . ')' : ''));
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td class="text-left pl-2 whitespace-nowrap"><?= esc((string) ($row->al_regdate ?? '')) ?></td>
|
||||||
|
<td class="text-left pl-2"><?= $who !== '' ? esc($who) : '<span class="text-gray-400">시스템</span>' ?></td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span style="<?= $actionBadge($act) ?>display:inline-block;padding:1px 8px;border-radius:9999px;font-size:11px;font-weight:700;">
|
||||||
|
<?= esc($actionLabels[$act] ?? $act) ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-left pl-2"><?= esc($tableLabels[$tbl] ?? $tbl) ?></td>
|
||||||
|
<td class="text-center"><?= (int) ($row->al_record_id ?? 0) ?: '-' ?></td>
|
||||||
|
<td class="text-left pl-2 text-gray-500"><?= esc((string) ($row->al_ip ?? '')) ?></td>
|
||||||
|
<td class="text-center no-print">
|
||||||
|
<a href="<?= base_url('admin/access/activity-logs/' . (int) $row->al_idx) ?>" class="text-blue-700 hover:underline">보기</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if ($list === []): ?>
|
||||||
|
<tr><td colspan="7" class="text-center text-gray-400 py-6">조회된 로그가 없습니다.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php if (isset($pager)): ?><div class="mt-3 no-print"><?= $pager->links() ?></div><?php endif; ?>
|
||||||
69
app/Views/admin/access/activity_log_detail.php
Normal file
69
app/Views/admin/access/activity_log_detail.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* @var object $row
|
||||||
|
* @var array<string,mixed> $before
|
||||||
|
* @var array<string,mixed> $after
|
||||||
|
* @var array<string,string> $tableLabels
|
||||||
|
* @var array<string,string> $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 ?? '');
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<section class="p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3 no-print">
|
||||||
|
<a href="<?= base_url('admin/access/activity-logs') ?>" class="text-sm text-blue-700 hover:underline">← 목록으로</a>
|
||||||
|
<button onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">인쇄</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="w-full data-table text-sm mb-5" style="max-width:680px;">
|
||||||
|
<tbody>
|
||||||
|
<tr><th class="text-left bg-gray-50 w-32">일시</th><td class="text-left pl-2"><?= esc((string) ($row->al_regdate ?? '')) ?></td></tr>
|
||||||
|
<tr><th class="text-left bg-gray-50">사용자</th><td class="text-left pl-2"><?= $who !== '' ? esc($who) : '시스템' ?></td></tr>
|
||||||
|
<tr><th class="text-left bg-gray-50">작업</th><td class="text-left pl-2"><?= esc($actionLabels[$act] ?? $act) ?></td></tr>
|
||||||
|
<tr><th class="text-left bg-gray-50">대상</th><td class="text-left pl-2"><?= esc($tableLabels[$tbl] ?? $tbl) ?> (<?= esc($tbl) ?>) · 번호 <?= (int) ($row->al_record_id ?? 0) ?></td></tr>
|
||||||
|
<tr><th class="text-left bg-gray-50">IP</th><td class="text-left pl-2"><?= esc((string) ($row->al_ip ?? '')) ?></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 class="font-bold text-gray-700 mb-2">변경 내역 (전 → 후)</h3>
|
||||||
|
<?php if ($allKeys === []): ?>
|
||||||
|
<p class="text-gray-400 text-sm">상세 데이터가 없습니다.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="border border-gray-300 rounded-lg overflow-auto">
|
||||||
|
<table class="w-full data-table text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-left w-40">항목</th>
|
||||||
|
<th class="text-left">변경 전</th>
|
||||||
|
<th class="text-left">변경 후</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($allKeys as $k): ?>
|
||||||
|
<?php
|
||||||
|
$b = array_key_exists($k, $before) ? $fmt($before[$k]) : null;
|
||||||
|
$a = array_key_exists($k, $after) ? $fmt($after[$k]) : null;
|
||||||
|
$changed = ($act === 'update') && ($b !== $a);
|
||||||
|
?>
|
||||||
|
<tr<?= $changed ? ' style="background:#fffbeb;"' : '' ?>>
|
||||||
|
<td class="text-left pl-2 font-semibold text-gray-700"><?= esc((string) $k) ?></td>
|
||||||
|
<td class="text-left pl-2 <?= $changed ? 'text-red-700' : 'text-gray-500' ?>"><?= $b === null ? '<span class="text-gray-300">—</span>' : esc($b) ?></td>
|
||||||
|
<td class="text-left pl-2 <?= $changed ? 'text-blue-700 font-semibold' : 'text-gray-700' ?>"><?= $a === null ? '<span class="text-gray-300">—</span>' : esc($a) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
@@ -33,7 +33,7 @@ if ($navItems === []) {
|
|||||||
$navItems = [
|
$navItems = [
|
||||||
['idx' => 0, 'name' => '대시보드', 'href' => 'admin', 'url' => base_url('admin'), 'children' => [], 'hasChildren' => false],
|
['idx' => 0, 'name' => '대시보드', 'href' => 'admin', 'url' => base_url('admin'), 'children' => [], 'hasChildren' => false],
|
||||||
['idx' => 0, 'name' => '회원·접근', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [
|
['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' => [
|
['idx' => 0, 'name' => '시스템', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [
|
||||||
$mk('역할', 'admin/roles'), $mk('메뉴', 'admin/menus'),
|
$mk('역할', 'admin/roles'), $mk('메뉴', 'admin/menus'),
|
||||||
|
|||||||
11
writable/database/activity_log_menu_add.sql
Normal file
11
writable/database/activity_log_menu_add.sql
Normal file
@@ -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'
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user