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:
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 = [
|
||||
['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'),
|
||||
|
||||
Reference in New Issue
Block a user