diff --git a/app/Http/Controllers/Admin/Log/AdminAuditLogController.php b/app/Http/Controllers/Admin/Log/AdminAuditLogController.php
new file mode 100644
index 0000000..115b33b
--- /dev/null
+++ b/app/Http/Controllers/Admin/Log/AdminAuditLogController.php
@@ -0,0 +1,43 @@
+service->indexData($request->query());
+
+ // ✅ view 파일명: 컨트롤러와 이름 맞춤 (index.blade.php 사용 안함)
+ return view('admin.log.AdminAuditLogController', $data);
+ }
+
+ /**
+ * AJAX 단건 조회 (모달 상세)
+ */
+ public function show(int $id, Request $request)
+ {
+ $item = $this->service->getItem($id);
+
+ if (!$item) {
+ return response()->json([
+ 'ok' => false,
+ 'message' => 'NOT_FOUND',
+ 'item' => null,
+ ], 404);
+ }
+
+ return response()->json([
+ 'ok' => true,
+ 'item' => $item,
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Admin/Log/MemberAccountLogController.php b/app/Http/Controllers/Admin/Log/MemberAccountLogController.php
new file mode 100644
index 0000000..ff1c2ea
--- /dev/null
+++ b/app/Http/Controllers/Admin/Log/MemberAccountLogController.php
@@ -0,0 +1,22 @@
+service->indexData($request->query());
+
+ // index.blade.php 금지 → 컨트롤러명과 동일
+ return view('admin.log.MemberAccountLogController', $data);
+ }
+}
diff --git a/app/Http/Controllers/Admin/Log/MemberDanalAuthTelLogController.php b/app/Http/Controllers/Admin/Log/MemberDanalAuthTelLogController.php
new file mode 100644
index 0000000..a5ce2ff
--- /dev/null
+++ b/app/Http/Controllers/Admin/Log/MemberDanalAuthTelLogController.php
@@ -0,0 +1,21 @@
+service->indexData($request->query());
+
+ return view('admin.log.member_danalauthtel_log', $data);
+ }
+}
diff --git a/app/Http/Controllers/Admin/Log/MemberJoinLogController.php b/app/Http/Controllers/Admin/Log/MemberJoinLogController.php
new file mode 100644
index 0000000..bb0f8ae
--- /dev/null
+++ b/app/Http/Controllers/Admin/Log/MemberJoinLogController.php
@@ -0,0 +1,22 @@
+service->indexData($request->query());
+
+ // ✅ index.blade.php 금지 → 컨트롤러명과 동일한 뷰 파일
+ return view('admin.log.MemberJoinLogController', $data);
+ }
+}
diff --git a/app/Http/Controllers/Admin/Log/MemberLoginLogController.php b/app/Http/Controllers/Admin/Log/MemberLoginLogController.php
new file mode 100644
index 0000000..73513d5
--- /dev/null
+++ b/app/Http/Controllers/Admin/Log/MemberLoginLogController.php
@@ -0,0 +1,22 @@
+service->indexData($request->query());
+
+ // index.blade.php 금지 → 컨트롤러명과 동일
+ return view('admin.log.MemberLoginLogController', $data);
+ }
+}
diff --git a/app/Http/Controllers/Admin/Log/MemberPasswdModifyLogController.php b/app/Http/Controllers/Admin/Log/MemberPasswdModifyLogController.php
new file mode 100644
index 0000000..0a1919e
--- /dev/null
+++ b/app/Http/Controllers/Admin/Log/MemberPasswdModifyLogController.php
@@ -0,0 +1,22 @@
+service->indexData($request->query());
+
+ // index.blade.php 금지 → 컨트롤러명과 동일
+ return view('admin.log.MemberPasswdModifyLogController', $data);
+ }
+}
diff --git a/app/Http/Controllers/Web/Mypage/InfoGateController.php b/app/Http/Controllers/Web/Mypage/InfoGateController.php
index 34d85e8..c10015b 100644
--- a/app/Http/Controllers/Web/Mypage/InfoGateController.php
+++ b/app/Http/Controllers/Web/Mypage/InfoGateController.php
@@ -413,6 +413,7 @@ final class InfoGateController extends Controller
$repo->updatePasswordOnly($memNo, $newPw);
$repo->logPasswordResetSuccess(
$memNo,
+ $email,
(string) $request->ip(),
(string) $request->userAgent(),
'S'
@@ -506,6 +507,12 @@ final class InfoGateController extends Controller
try {
$repo->updatePin2Only($memNo, $newPin2);
+ $repo->logPasswordResetSuccess2Only(
+ $memNo,
+ $email,
+ (string) $request->ip(),
+ (string) $request->userAgent()
+ );
} catch (\Throwable $e) {
Log::error('[mypage] pin2 update failed', [
'mem_no' => $memNo,
diff --git a/app/Repositories/Admin/Log/AdminAuditLogRepository.php b/app/Repositories/Admin/Log/AdminAuditLogRepository.php
new file mode 100644
index 0000000..b10916e
--- /dev/null
+++ b/app/Repositories/Admin/Log/AdminAuditLogRepository.php
@@ -0,0 +1,99 @@
+leftJoin('admin_users as au', 'au.id', '=', 'l.actor_admin_user_id')
+ ->select([
+ 'l.id',
+ 'l.actor_admin_user_id',
+ 'l.action',
+ 'l.target_type',
+ 'l.target_id',
+ 'l.ip',
+ 'l.created_at',
+ 'au.email as actor_email',
+ 'au.name as actor_name',
+ ]);
+
+ // 기간
+ if (!empty($filters['date_from'])) {
+ $q->where('l.created_at', '>=', $filters['date_from'] . ' 00:00:00');
+ }
+ if (!empty($filters['date_to'])) {
+ $q->where('l.created_at', '<=', $filters['date_to'] . ' 23:59:59');
+ }
+
+ // actor 검색 (email/name)
+ $actorQ = trim((string)($filters['actor_q'] ?? ''));
+ if ($actorQ !== '') {
+ $like = '%' . $this->escapeLike($actorQ) . '%';
+ $q->where(function ($qq) use ($like) {
+ $qq->where('au.email', 'like', $like)
+ ->orWhere('au.name', 'like', $like);
+ });
+ }
+
+ // action (prefix match)
+ $action = trim((string)($filters['action'] ?? ''));
+ if ($action !== '') {
+ $q->where('l.action', 'like', $this->escapeLike($action) . '%');
+ }
+
+ // target_type (prefix match)
+ $tt = trim((string)($filters['target_type'] ?? ''));
+ if ($tt !== '') {
+ $q->where('l.target_type', 'like', $this->escapeLike($tt) . '%');
+ }
+
+ // ip (prefix match)
+ $ip = trim((string)($filters['ip'] ?? ''));
+ if ($ip !== '') {
+ $q->where('l.ip', 'like', $this->escapeLike($ip) . '%');
+ }
+
+ // ✅ 최신순
+ $q->orderByDesc('l.created_at')->orderByDesc('l.id');
+
+ return $q->paginate($perPage)->withQueryString();
+ }
+
+ public function findOne(int $id): ?array
+ {
+ $row = DB::table(self::TABLE . ' as l')
+ ->leftJoin('admin_users as au', 'au.id', '=', 'l.actor_admin_user_id')
+ ->select([
+ 'l.id',
+ 'l.actor_admin_user_id',
+ 'l.action',
+ 'l.target_type',
+ 'l.target_id',
+ 'l.before_json',
+ 'l.after_json',
+ 'l.ip',
+ 'l.user_agent',
+ 'l.created_at',
+ 'au.email as actor_email',
+ 'au.name as actor_name',
+ ])
+ ->where('l.id', $id)
+ ->first();
+
+ return $row ? (array)$row : null;
+ }
+
+ private function escapeLike(string $s): string
+ {
+ // MySQL LIKE escape (%, _)
+ return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $s);
+ }
+}
diff --git a/app/Repositories/Admin/Log/MemberAccountLogRepository.php b/app/Repositories/Admin/Log/MemberAccountLogRepository.php
new file mode 100644
index 0000000..fbd0cc0
--- /dev/null
+++ b/app/Repositories/Admin/Log/MemberAccountLogRepository.php
@@ -0,0 +1,97 @@
+select([
+ 'l.seq',
+ 'l.mem_no',
+ 'l.request_data',
+ 'l.result_data',
+ 'l.request_time',
+ 'l.result_time',
+ ]);
+
+ // 기간(요청시간 기준)
+ if (!empty($filters['date_from'])) {
+ $q->where('l.request_time', '>=', $filters['date_from'] . ' 00:00:00');
+ }
+ if (!empty($filters['date_to'])) {
+ $q->where('l.request_time', '<=', $filters['date_to'] . ' 23:59:59');
+ }
+
+ // mem_no
+ if (($filters['mem_no'] ?? null) !== null) {
+ $q->where('l.mem_no', (int)$filters['mem_no']);
+ }
+
+ // bank_code (요청 JSON)
+ $bank = trim((string)($filters['bank_code'] ?? ''));
+ if ($bank !== '') {
+ $q->whereRaw(
+ "JSON_UNQUOTE(JSON_EXTRACT(l.request_data, '$.bank_code')) = ?",
+ [$bank]
+ );
+ }
+
+ // status (결과 JSON)
+ $status = trim((string)($filters['status'] ?? ''));
+ if ($status !== '') {
+ if ($status === 'ok') {
+ $q->whereRaw("JSON_EXTRACT(l.result_data, '$.status') = 200");
+ } elseif ($status === 'fail') {
+ $q->whereRaw("JSON_EXTRACT(l.result_data, '$.status') <> 200");
+ } elseif (ctype_digit($status)) {
+ $q->whereRaw("JSON_EXTRACT(l.result_data, '$.status') = ?", [(int)$status]);
+ }
+ }
+
+ // account (요청 JSON) - 부분검색
+ $account = trim((string)($filters['account'] ?? ''));
+ if ($account !== '') {
+ $like = '%' . $this->escapeLike($account) . '%';
+ $q->whereRaw(
+ "JSON_UNQUOTE(JSON_EXTRACT(l.request_data, '$.account')) LIKE ?",
+ [$like]
+ );
+ }
+
+ // name (요청 JSON: mam_accountname)
+ $name = trim((string)($filters['name'] ?? ''));
+ if ($name !== '') {
+ $like = '%' . $this->escapeLike($name) . '%';
+ $q->whereRaw(
+ "JSON_UNQUOTE(JSON_EXTRACT(l.request_data, '$.mam_accountname')) LIKE ?",
+ [$like]
+ );
+ }
+
+ // q: request/result 전체 LIKE (운영에서 필요할 때만)
+ $kw = trim((string)($filters['q'] ?? ''));
+ if ($kw !== '') {
+ $like = '%' . $this->escapeLike($kw) . '%';
+ $q->where(function ($qq) use ($like) {
+ $qq->where('l.request_data', 'like', $like)
+ ->orWhere('l.result_data', 'like', $like);
+ });
+ }
+
+ $q->orderByDesc('l.request_time')->orderByDesc('l.seq');
+
+ return $q->paginate($perPage)->withQueryString();
+ }
+
+ private function escapeLike(string $s): string
+ {
+ return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $s);
+ }
+}
diff --git a/app/Repositories/Admin/Log/MemberDanalAuthTelLogRepository.php b/app/Repositories/Admin/Log/MemberDanalAuthTelLogRepository.php
new file mode 100644
index 0000000..0601524
--- /dev/null
+++ b/app/Repositories/Admin/Log/MemberDanalAuthTelLogRepository.php
@@ -0,0 +1,43 @@
+select([
+ 'seq','gubun','TID','res_code','mem_no','info','rgdate',
+ // ✅ MariaDB: CAST(... AS JSON) 금지 → info에 바로 JSON_EXTRACT
+ DB::raw("JSON_UNQUOTE(JSON_EXTRACT(`info`, '$.mobile_number')) AS mobile_number"),
+ DB::raw("JSON_UNQUOTE(JSON_EXTRACT(`info`, '$._mno')) AS info_mno"),
+ ]);
+
+ // mem_no 검색: 컬럼 mem_no + info._mno 둘 다
+ $memNo = (string)($filters['mem_no'] ?? '');
+ if ($memNo !== '') {
+ $q->where(function ($qq) use ($memNo) {
+ $qq->where('mem_no', (int)$memNo)
+ ->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(`info`, '$._mno')) = ?", [$memNo]);
+ });
+ }
+
+ // phone 검색: 숫자만 받아서 부분검색
+ $pd = (string)($filters['phone_digits'] ?? '');
+ if ($pd !== '') {
+ $q->whereRaw(
+ "REPLACE(JSON_UNQUOTE(JSON_EXTRACT(`info`, '$.mobile_number')),'-','') LIKE ?",
+ ['%'.$pd.'%']
+ );
+ }
+
+ $q->orderByDesc('rgdate')->orderByDesc('seq');
+
+ return $q->paginate($perPage)->withQueryString();
+ }
+}
diff --git a/app/Repositories/Admin/Log/MemberJoinLogRepository.php b/app/Repositories/Admin/Log/MemberJoinLogRepository.php
new file mode 100644
index 0000000..77b5ca4
--- /dev/null
+++ b/app/Repositories/Admin/Log/MemberJoinLogRepository.php
@@ -0,0 +1,83 @@
+select([
+ 'l.seq',
+ 'l.gubun',
+ 'l.mem_no',
+ 'l.cell_corp',
+ 'l.cell_phone',
+ 'l.email',
+ 'l.ip4',
+ 'l.ip4_c',
+ 'l.error_code',
+ 'l.dt_reg',
+ ]);
+
+ // 기간(dt_reg)
+ if (!empty($filters['date_from'])) {
+ $q->where('l.dt_reg', '>=', $filters['date_from'] . ' 00:00:00');
+ }
+ if (!empty($filters['date_to'])) {
+ $q->where('l.dt_reg', '<=', $filters['date_to'] . ' 23:59:59');
+ }
+
+ // gubun
+ $gubun = trim((string)($filters['gubun'] ?? ''));
+ if ($gubun !== '') $q->where('l.gubun', $gubun);
+
+ // error_code (2자리)
+ $err = trim((string)($filters['error_code'] ?? ''));
+ if ($err !== '') $q->where('l.error_code', $err);
+
+ // mem_no
+ if (($filters['mem_no'] ?? null) !== null) {
+ $q->where('l.mem_no', (int)$filters['mem_no']);
+ }
+
+ // phone exact (encrypt 결과)
+ if (!empty($filters['phone_enc'])) {
+ $q->where('l.cell_phone', (string)$filters['phone_enc']);
+ }
+
+ // email (부분검색)
+ $email = trim((string)($filters['email'] ?? ''));
+ if ($email !== '') {
+ $like = '%' . $this->escapeLike($email) . '%';
+ $q->where('l.email', 'like', $like);
+ }
+
+ // ip4 prefix
+ $ip4 = trim((string)($filters['ip4'] ?? ''));
+ if ($ip4 !== '') {
+ $q->where('l.ip4', 'like', $this->escapeLike($ip4) . '%');
+ }
+
+ // ip4_c prefix
+ $ip4c = trim((string)($filters['ip4_c'] ?? ''));
+ if ($ip4c !== '') {
+ $q->where('l.ip4_c', 'like', $this->escapeLike($ip4c) . '%');
+ }
+
+ // 최신순
+ $q->orderByDesc('l.dt_reg')->orderByDesc('l.seq');
+
+ return $q->paginate($perPage)->withQueryString();
+ }
+
+ private function escapeLike(string $s): string
+ {
+ return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $s);
+ }
+}
diff --git a/app/Repositories/Admin/Log/MemberLoginLogRepository.php b/app/Repositories/Admin/Log/MemberLoginLogRepository.php
new file mode 100644
index 0000000..01e56a3
--- /dev/null
+++ b/app/Repositories/Admin/Log/MemberLoginLogRepository.php
@@ -0,0 +1,88 @@
+select([
+ 'l.seq',
+ 'l.mem_no',
+ 'l.sf',
+ 'l.conn',
+ 'l.ip4',
+ 'l.ip4_c',
+ 'l.error_code',
+ 'l.dt_reg',
+ 'l.platform',
+ 'l.browser',
+ ]);
+
+ // 기간(dt_reg)
+ if (!empty($filters['date_from'])) {
+ $q->where('l.dt_reg', '>=', $filters['date_from'] . ' 00:00:00');
+ }
+ if (!empty($filters['date_to'])) {
+ $q->where('l.dt_reg', '<=', $filters['date_to'] . ' 23:59:59.999999');
+ }
+
+ // mem_no
+ if (($filters['mem_no'] ?? null) !== null) {
+ $q->where('l.mem_no', (int)$filters['mem_no']);
+ }
+
+ // 성공/실패
+ if (!empty($filters['sf'])) {
+ $q->where('l.sf', $filters['sf']);
+ }
+
+ // 접속경로 1/2
+ if (!empty($filters['conn'])) {
+ $q->where('l.conn', $filters['conn']);
+ }
+
+ // ip prefix
+ $ip4 = trim((string)($filters['ip4'] ?? ''));
+ if ($ip4 !== '') {
+ $q->where('l.ip4', 'like', $this->escapeLike($ip4) . '%');
+ }
+
+ $ip4c = trim((string)($filters['ip4_c'] ?? ''));
+ if ($ip4c !== '') {
+ $q->where('l.ip4_c', 'like', $this->escapeLike($ip4c) . '%');
+ }
+
+ // error_code
+ $err = trim((string)($filters['error_code'] ?? ''));
+ if ($err !== '') {
+ $q->where('l.error_code', $err);
+ }
+
+ // platform/browser (contains)
+ $platform = trim((string)($filters['platform'] ?? ''));
+ if ($platform !== '') {
+ $q->where('l.platform', 'like', '%' . $this->escapeLike($platform) . '%');
+ }
+
+ $browser = trim((string)($filters['browser'] ?? ''));
+ if ($browser !== '') {
+ $q->where('l.browser', 'like', '%' . $this->escapeLike($browser) . '%');
+ }
+
+ $q->orderByDesc('l.dt_reg')->orderByDesc('l.seq');
+
+ return $q->paginate($perPage)->withQueryString();
+ }
+
+ private function escapeLike(string $s): string
+ {
+ return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $s);
+ }
+}
diff --git a/app/Repositories/Admin/Log/MemberPasswdModifyLogRepository.php b/app/Repositories/Admin/Log/MemberPasswdModifyLogRepository.php
new file mode 100644
index 0000000..abd4f44
--- /dev/null
+++ b/app/Repositories/Admin/Log/MemberPasswdModifyLogRepository.php
@@ -0,0 +1,85 @@
+select(['p.seq', 'p.state', 'p.info', 'p.rgdate']);
+
+ // 기간
+ if (!empty($filters['date_from'])) {
+ $q->where('p.rgdate', '>=', $filters['date_from'] . ' 00:00:00');
+ }
+ if (!empty($filters['date_to'])) {
+ $q->where('p.rgdate', '<=', $filters['date_to'] . ' 23:59:59');
+ }
+
+ // state
+ if (!empty($filters['state'])) {
+ $q->where('p.state', $filters['state']);
+ }
+
+ // mem_no (JSON)
+ if (($filters['mem_no'] ?? null) !== null) {
+ $q->whereRaw(
+ "JSON_UNQUOTE(JSON_EXTRACT(p.info, '$.mem_no')) = ?",
+ [(string)((int)$filters['mem_no'])]
+ );
+ }
+
+ // type (신포맷)
+ $type = trim((string)($filters['type'] ?? ''));
+ if ($type !== '') {
+ $q->whereRaw(
+ "JSON_UNQUOTE(JSON_EXTRACT(p.info, '$.type')) = ?",
+ [$type]
+ );
+ }
+
+ // email (구포맷)
+ $email = trim((string)($filters['email'] ?? ''));
+ if ($email !== '') {
+ $like = '%' . $this->escapeLike($email) . '%';
+
+ $q->where(function ($qq) use ($like) {
+ $qq->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(p.info, '$.user_email')) LIKE ?", [$like])
+ ->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(p.info, '$.email')) LIKE ?", [$like]);
+ });
+ }
+
+ // ip: 신포맷 remote_addr prefix OR 구포맷 auth_key/info contains
+ $ip = trim((string)($filters['ip'] ?? ''));
+ if ($ip !== '') {
+ $like = $this->escapeLike($ip) . '%';
+ $like2 = '%' . $this->escapeLike($ip) . '%';
+
+ $q->where(function ($qq) use ($like, $like2) {
+ $qq->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(p.info, '$.remote_addr')) LIKE ?", [$like])
+ ->orWhere('p.info', 'like', $like2);
+ });
+ }
+
+ // q: info 전체 검색(필요할 때만)
+ $kw = trim((string)($filters['q'] ?? ''));
+ if ($kw !== '') {
+ $q->where('p.info', 'like', '%' . $this->escapeLike($kw) . '%');
+ }
+
+ $q->orderByDesc('p.rgdate')->orderByDesc('p.seq');
+
+ return $q->paginate($perPage)->withQueryString();
+ }
+
+ private function escapeLike(string $s): string
+ {
+ return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $s);
+ }
+}
diff --git a/app/Repositories/Member/MemberAuthRepository.php b/app/Repositories/Member/MemberAuthRepository.php
index 52dd1b4..4f822b4 100644
--- a/app/Repositories/Member/MemberAuthRepository.php
+++ b/app/Repositories/Member/MemberAuthRepository.php
@@ -742,7 +742,7 @@ class MemberAuthRepository
}
- public function logPasswordResetSuccess(int $memNo, string $ip, string $agent, string $state = 'E'): void
+ public function logPasswordResetSuccess(int $memNo, string $email, string $ip, string $agent, string $state = 'E'): void
{
$now = now()->format('Y-m-d H:i:s');
@@ -756,6 +756,27 @@ class MemberAuthRepository
'state' => $state,
'info' => json_encode([
'mem_no' => (string)$memNo,
+ 'email' => $email,
+ 'type' => "로그인비밀번호변경",
+ 'redate' => $now,
+ 'remote_addr' => $ip,
+ 'agent' => substr((string)$agent, 0, 500), // 길이 방어(일관 처리)
+ ], JSON_UNESCAPED_UNICODE),
+ 'rgdate' => $now,
+ ]);
+ }
+
+ public function logPasswordResetSuccess2Only(int $memNo, string $email, string $ip, string $agent): void
+ {
+ $now = now()->format('Y-m-d H:i:s');
+ $state = 'S';
+
+ DB::table('mem_passwd_modify')->insert([
+ 'state' => $state,
+ 'info' => json_encode([
+ 'mem_no' => (string)$memNo,
+ 'email' => $email,
+ 'type' => "2차비밀번호변경",
'redate' => $now,
'remote_addr' => $ip,
'agent' => substr((string)$agent, 0, 500), // 길이 방어(일관 처리)
diff --git a/app/Services/Admin/Log/AdminAuditLogService.php b/app/Services/Admin/Log/AdminAuditLogService.php
new file mode 100644
index 0000000..99842fe
--- /dev/null
+++ b/app/Services/Admin/Log/AdminAuditLogService.php
@@ -0,0 +1,106 @@
+ $this->safeDate($query['date_from'] ?? ''),
+ 'date_to' => $this->safeDate($query['date_to'] ?? ''),
+ 'actor_q' => $this->safeStr($query['actor_q'] ?? '', 80),
+ 'action' => $this->safeStr($query['action'] ?? '', 60),
+ 'target_type' => $this->safeStr($query['target_type'] ?? '', 80),
+ 'ip' => $this->safeStr($query['ip'] ?? '', 45),
+ ];
+
+ // ✅ 기간 역전 방지
+ if ($filters['date_from'] && $filters['date_to']) {
+ if (strcmp($filters['date_from'], $filters['date_to']) > 0) {
+ [$filters['date_from'], $filters['date_to']] = [$filters['date_to'], $filters['date_from']];
+ }
+ }
+
+ $page = $this->repo->paginate($filters, 30);
+
+ return [
+ 'page' => $page,
+ 'items' => $page->items(),
+ 'filters' => $filters,
+ ];
+ }
+
+ public function getItem(int $id): ?array
+ {
+ $row = $this->repo->findOne($id);
+ if (!$row) return null;
+
+ $beforeRaw = $row['before_json'] ?? null;
+ $afterRaw = $row['after_json'] ?? null;
+
+ return [
+ 'id' => (int)($row['id'] ?? 0),
+
+ 'actor_admin_user_id' => (int)($row['actor_admin_user_id'] ?? 0),
+ 'actor_email' => (string)($row['actor_email'] ?? ''),
+ 'actor_name' => (string)($row['actor_name'] ?? ''),
+
+ 'action' => (string)($row['action'] ?? ''),
+ 'target_type' => (string)($row['target_type'] ?? ''),
+ 'target_id' => (int)($row['target_id'] ?? 0),
+
+ 'ip' => (string)($row['ip'] ?? ''),
+ 'created_at' => (string)($row['created_at'] ?? ''),
+
+ 'user_agent' => (string)($row['user_agent'] ?? ''),
+
+ // ✅ pretty 출력 (모달에서 바로 textContent로 넣기 좋게)
+ 'before_pretty' => $this->prettyJson($beforeRaw),
+ 'after_pretty' => $this->prettyJson($afterRaw),
+
+ // 원문도 필요하면 남겨둠
+ 'before_raw' => $beforeRaw,
+ 'after_raw' => $afterRaw,
+ ];
+ }
+
+ private function safeStr(mixed $v, int $max): string
+ {
+ $s = trim((string)$v);
+ if ($s === '') return '';
+ if (mb_strlen($s) > $max) $s = mb_substr($s, 0, $max);
+ return $s;
+ }
+
+ private function safeDate(mixed $v): ?string
+ {
+ $s = trim((string)$v);
+ if ($s === '') return null;
+ if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $s)) return null;
+ return $s;
+ }
+
+ private function prettyJson(?string $raw): string
+ {
+ $raw = $raw !== null ? trim((string)$raw) : '';
+ if ($raw === '') return '-';
+
+ $decoded = json_decode($raw, true);
+ if (!is_array($decoded) && !is_object($decoded)) {
+ // 깨진 JSON이면 원문 출력
+ return $raw;
+ }
+
+ return json_encode(
+ $decoded,
+ JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
+ ) ?: $raw;
+ }
+}
diff --git a/app/Services/Admin/Log/MemberAccountLogService.php b/app/Services/Admin/Log/MemberAccountLogService.php
new file mode 100644
index 0000000..36e5c88
--- /dev/null
+++ b/app/Services/Admin/Log/MemberAccountLogService.php
@@ -0,0 +1,160 @@
+ $this->safeDate($query['date_from'] ?? ''),
+ 'date_to' => $this->safeDate($query['date_to'] ?? ''),
+
+ 'mem_no' => $this->safeInt($query['mem_no'] ?? null),
+ 'bank_code' => $this->safeStr($query['bank_code'] ?? '', 10),
+ 'status' => $this->safeStr($query['status'] ?? '', 10), // ok/fail/200/520...
+
+ 'account' => $this->safeStr($query['account'] ?? '', 40),
+ 'name' => $this->safeStr($query['name'] ?? '', 40),
+ 'q' => $this->safeStr($query['q'] ?? '', 120),
+ ];
+
+ if ($filters['date_from'] && $filters['date_to']) {
+ if (strcmp($filters['date_from'], $filters['date_to']) > 0) {
+ [$filters['date_from'], $filters['date_to']] = [$filters['date_to'], $filters['date_from']];
+ }
+ }
+
+ $page = $this->repo->paginate($filters, 30);
+
+ $items = [];
+ foreach ($page->items() as $it) {
+ $r = is_array($it) ? $it : (array)$it;
+
+ $reqRaw = (string)($r['request_data'] ?? '');
+ $resRaw = (string)($r['result_data'] ?? '');
+
+ $req = $this->decodeJson($reqRaw);
+ $res = $this->decodeJson($resRaw);
+
+ $memNo = (int)($r['mem_no'] ?? 0);
+
+ $account = trim((string)($req['account'] ?? ''));
+ $accountMasked = $this->maskAccount($account);
+
+ $bankCode = trim((string)($req['bank_code'] ?? ''));
+ $proType = trim((string)($req['account_protype'] ?? ''));
+ $name = trim((string)($req['mam_accountname'] ?? ''));
+
+ $status = (int)($res['status'] ?? 0);
+ $ok = ($status === 200);
+
+ $depositor = trim((string)($res['depositor'] ?? ''));
+ $errCode = trim((string)($res['error_code'] ?? ''));
+ $errMsg = trim((string)($res['error_message'] ?? ''));
+
+ // JSON pretty (api_key는 마스킹)
+ $reqPretty = $this->prettyJson($this->maskApiKey($req), $reqRaw);
+ $resPretty = $this->prettyJson($res, $resRaw);
+
+ $items[] = array_merge($r, [
+ 'mem_link' => $memNo > 0 ? ('/members/' . $memNo) : null,
+
+ 'account' => $account,
+ 'account_masked' => $accountMasked,
+ 'bank_code' => $bankCode,
+ 'account_protype' => $proType,
+ 'mam_accountname' => $name,
+
+ 'status_int' => $status,
+ 'status_label' => $ok ? 'SUCCESS' : 'FAIL',
+ 'status_badge' => $ok ? 'badge--ok' : 'badge--bad',
+
+ 'depositor' => $depositor,
+ 'error_code' => $errCode,
+ 'error_message' => $errMsg,
+
+ 'request_pretty' => $reqPretty,
+ 'result_pretty' => $resPretty,
+ ]);
+ }
+
+ return [
+ 'filters' => $filters,
+ 'page' => $page,
+ 'items' => $items,
+ ];
+ }
+
+ private function decodeJson(string $raw): array
+ {
+ $raw = trim($raw);
+ if ($raw === '') return [];
+ $arr = json_decode($raw, true);
+ return is_array($arr) ? $arr : [];
+ }
+
+ private function prettyJson(array $arr, string $fallbackRaw): string
+ {
+ if (!empty($arr)) {
+ return (string)json_encode($arr, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ }
+ return $fallbackRaw;
+ }
+
+ private function maskApiKey(array $req): array
+ {
+ if (!isset($req['api_key'])) return $req;
+
+ $v = (string)$req['api_key'];
+ $v = trim($v);
+ if ($v === '') return $req;
+
+ // 앞 4 + ... + 뒤 4
+ $head = mb_substr($v, 0, 4);
+ $tail = mb_substr($v, -4);
+ $req['api_key'] = $head . '…' . $tail;
+
+ return $req;
+ }
+
+ private function maskAccount(string $account): string
+ {
+ $s = trim($account);
+ if ($s === '') return '';
+
+ $len = mb_strlen($s);
+ if ($len <= 4) return $s;
+
+ return str_repeat('*', max(0, $len - 4)) . mb_substr($s, -4);
+ }
+
+ private function safeStr(mixed $v, int $max): string
+ {
+ $s = trim((string)$v);
+ if ($s === '') return '';
+ if (mb_strlen($s) > $max) $s = mb_substr($s, 0, $max);
+ return $s;
+ }
+
+ private function safeInt(mixed $v): ?int
+ {
+ if ($v === null || $v === '') return null;
+ if (!is_numeric($v)) return null;
+ $n = (int)$v;
+ return $n >= 0 ? $n : null;
+ }
+
+ private function safeDate(mixed $v): ?string
+ {
+ $s = trim((string)$v);
+ if ($s === '') return null;
+ return preg_match('/^\d{4}-\d{2}-\d{2}$/', $s) ? $s : null;
+ }
+}
diff --git a/app/Services/Admin/Log/MemberDanalAuthTelLogService.php b/app/Services/Admin/Log/MemberDanalAuthTelLogService.php
new file mode 100644
index 0000000..053f4db
--- /dev/null
+++ b/app/Services/Admin/Log/MemberDanalAuthTelLogService.php
@@ -0,0 +1,92 @@
+ trim((string)($query['mem_no'] ?? '')),
+ 'phone' => trim((string)($query['phone'] ?? '')),
+ ];
+
+ // mem_no: 숫자만
+ if ($filters['mem_no'] !== '' && !ctype_digit($filters['mem_no'])) {
+ $filters['mem_no'] = '';
+ }
+
+ // phone: 숫자만 (10~11자리 아니어도 부분검색 가능하게 숫자만 유지)
+ $filters['phone_digits'] = preg_replace('/\D+/', '', $filters['phone']) ?: '';
+
+ $page = $this->repo->paginate($filters, 30);
+
+ $rows = [];
+ foreach ($page->items() as $it) {
+ $r = is_array($it) ? $it : (array)$it;
+
+ $phone = (string)($r['mobile_number'] ?? '');
+ $r['phone_digits'] = preg_replace('/\D+/', '', $phone) ?: '';
+ $r['phone_display'] = $this->formatPhone($r['phone_digits']) ?: $phone;
+
+ // JSON(모달용) - 개인정보 최소화 (mem_no/전화 + 결과/거래정보만)
+ $infoArr = $this->decodeJson((string)($r['info'] ?? ''));
+ $r['info_sanitized'] = $this->sanitizeInfo($infoArr, $r);
+
+ $rows[] = $r;
+ }
+
+ return [
+ 'page' => $page,
+ 'rows' => $rows,
+ 'filters' => $filters,
+ ];
+ }
+
+ private function decodeJson(string $json): array
+ {
+ $json = trim($json);
+ if ($json === '') return [];
+ $arr = json_decode($json, true);
+ return is_array($arr) ? $arr : [];
+ }
+
+ private function sanitizeInfo(array $info, array $row): array
+ {
+ // 허용 필드만 남김 (요구사항: mem_no + 전화만 노출)
+ // + 로그 판단에 필요한 최소 정보(RETURNCODE/RETURNMSG/TID/telecom 등)만
+ $out = [];
+
+ $out['gubun'] = (string)($row['gubun'] ?? '');
+ $out['res_code'] = (string)($row['res_code'] ?? '');
+ $out['TID'] = (string)($row['TID'] ?? '');
+ $out['mem_no'] = (string)($row['mem_no'] ?? '');
+
+ // info에서 가져오되, 화면엔 전화번호만
+ $out['mobile_number'] = (string)($info['mobile_number'] ?? ($row['mobile_number'] ?? ''));
+
+ // 결과코드/메시지(있으면)
+ $out['RETURNCODE'] = (string)($info['RETURNCODE'] ?? ($info['res_code'] ?? ''));
+ $out['RETURNMSG'] = (string)($info['RETURNMSG'] ?? '');
+
+ // telecom은 개인식별 정보는 아니지만, 원하면 모달에서도 빼도 됨.
+ // 지금은 로그 파악에 도움돼서 유지.
+ if (isset($info['telecom'])) $out['telecom'] = (string)$info['telecom'];
+
+ // CI/DI/name/birthday/sex/email 등은 완전히 제거
+ return array_filter($out, fn($v) => $v !== '');
+ }
+
+ private function formatPhone(string $digits): string
+ {
+ if (!preg_match('/^\d{10,11}$/', $digits)) return '';
+ if (strlen($digits) === 11) return substr($digits, 0, 3).'-'.substr($digits, 3, 4).'-'.substr($digits, 7, 4);
+ return substr($digits, 0, 3).'-'.substr($digits, 3, 3).'-'.substr($digits, 6, 4);
+ }
+}
diff --git a/app/Services/Admin/Log/MemberJoinLogService.php b/app/Services/Admin/Log/MemberJoinLogService.php
new file mode 100644
index 0000000..821fc55
--- /dev/null
+++ b/app/Services/Admin/Log/MemberJoinLogService.php
@@ -0,0 +1,179 @@
+ $this->safeDate($query['date_from'] ?? ''),
+ 'date_to' => $this->safeDate($query['date_to'] ?? ''),
+
+ 'gubun' => $this->safeStr($query['gubun'] ?? '', 50),
+ 'error_code' => $this->safeStr($query['error_code'] ?? '', 2),
+
+ 'mem_no' => $this->safeInt($query['mem_no'] ?? null),
+ 'email' => $this->safeStr($query['email'] ?? '', 80),
+
+ 'ip4' => $this->safeIpPrefix($query['ip4'] ?? ''),
+ 'ip4_c' => $this->safeIpPrefix($query['ip4_c'] ?? ''),
+
+ // 사용자 입력 전화번호(plain)
+ 'phone' => $this->safeStr($query['phone'] ?? '', 30),
+ // 검색용 encrypt 결과(정확일치)
+ 'phone_enc' => null,
+ ];
+
+ // ✅ 기간 역전 방지
+ if ($filters['date_from'] && $filters['date_to']) {
+ if (strcmp($filters['date_from'], $filters['date_to']) > 0) {
+ [$filters['date_from'], $filters['date_to']] = [$filters['date_to'], $filters['date_from']];
+ }
+ }
+
+ // ✅ 전화번호 검색: encrypt(숫자) = cell_phone (정확일치)
+ $phoneDigits = preg_replace('/\D+/', '', (string)$filters['phone']) ?: '';
+ if ($phoneDigits !== '' && preg_match('/^\d{10,11}$/', $phoneDigits)) {
+ try {
+ $seed = app(CiSeedCrypto::class);
+ $filters['phone_enc'] = (string)$seed->encrypt($phoneDigits);
+ } catch (\Throwable $e) {
+ // encrypt 실패하면 검색조건 무시(리스트는 정상 출력)
+ $filters['phone_enc'] = null;
+ }
+ }
+
+ $page = $this->repo->paginate($filters, 30);
+
+ // ✅ 리스트 표시용 가공(복호화/포맷)
+ $seed = app(CiSeedCrypto::class);
+
+ $items = [];
+ foreach ($page->items() as $it) {
+ $r = is_array($it) ? $it : (array)$it;
+
+ [$corpLabel, $corpBadge] = $this->corpLabel((string)($r['cell_corp'] ?? 'n'));
+
+ $phoneEnc = (string)($r['cell_phone'] ?? '');
+ $phoneDigits = $this->decryptPhoneDigits($seed, $phoneEnc);
+ $phoneDisplay = $this->formatPhone($phoneDigits);
+
+ $email = trim((string)($r['email'] ?? ''));
+ if ($email === '' || $email === '-') $email = '-';
+
+ $memNo = (int)($r['mem_no'] ?? 0);
+
+ $items[] = array_merge($r, [
+ 'corp_label' => $corpLabel,
+ 'corp_badge' => $corpBadge,
+
+ 'phone_plain' => $phoneDigits,
+ 'phone_display' => ($phoneDisplay !== '' ? $phoneDisplay : ($phoneDigits !== '' ? $phoneDigits : '-')),
+
+ 'email_display' => $email,
+
+ 'mem_no_int' => $memNo,
+ 'mem_link' => ($memNo > 0) ? ('/members/' . $memNo) : null,
+ ]);
+ }
+
+ return [
+ 'page' => $page,
+ 'items' => $items,
+ 'filters' => $filters,
+ 'corp_map' => $this->corpMapForUi(),
+ ];
+ }
+
+ private function safeStr(mixed $v, int $max): string
+ {
+ $s = trim((string)$v);
+ if ($s === '') return '';
+ if (mb_strlen($s) > $max) $s = mb_substr($s, 0, $max);
+ return $s;
+ }
+
+ private function safeInt(mixed $v): ?int
+ {
+ if ($v === null || $v === '') return null;
+ if (!is_numeric($v)) return null;
+ $n = (int)$v;
+ return $n >= 0 ? $n : null;
+ }
+
+ private function safeDate(mixed $v): ?string
+ {
+ $s = trim((string)$v);
+ if ($s === '') return null;
+ if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $s)) return null;
+ return $s;
+ }
+
+ private function safeIpPrefix(mixed $v): string
+ {
+ $s = trim((string)$v);
+ if ($s === '') return '';
+ // prefix 검색 용도: 숫자/점만 허용
+ if (!preg_match('/^[0-9.]+$/', $s)) return '';
+ return $s;
+ }
+
+ private function decryptPhoneDigits(CiSeedCrypto $seed, string $enc): string
+ {
+ $enc = trim($enc);
+ if ($enc === '') return '';
+
+ try {
+ $plain = (string)$seed->decrypt($enc);
+ $digits = preg_replace('/\D+/', '', $plain) ?: '';
+ return preg_match('/^\d{10,11}$/', $digits) ? $digits : '';
+ } catch (\Throwable $e) {
+ return '';
+ }
+ }
+
+ private function formatPhone(string $digits): string
+ {
+ if (!preg_match('/^\d{10,11}$/', $digits)) return '';
+ if (strlen($digits) === 11) {
+ return substr($digits, 0, 3) . '-' . substr($digits, 3, 4) . '-' . substr($digits, 7, 4);
+ }
+ return substr($digits, 0, 3) . '-' . substr($digits, 3, 3) . '-' . substr($digits, 6, 4);
+ }
+
+ private function corpLabel(string $code): array
+ {
+ $map = [
+ '01' => ['SKT', 'badge--skt'],
+ '02' => ['KT', 'badge--kt'],
+ '03' => ['LGU+', 'badge--lgu'],
+ '04' => ['SKT(알뜰)', 'badge--mvno'],
+ '05' => ['KT(알뜰)', 'badge--mvno'],
+ '06' => ['LGU+(알뜰)', 'badge--mvno'],
+ 'n' => ['-', 'badge--muted'],
+ ];
+
+ return $map[$code] ?? ['-', 'badge--muted'];
+ }
+
+ private function corpMapForUi(): array
+ {
+ return [
+ '' => '전체',
+ '01' => 'SKT',
+ '02' => 'KT',
+ '03' => 'LGU+',
+ '04' => 'SKT(알뜰)',
+ '05' => 'KT(알뜰)',
+ '06' => 'LGU+(알뜰)',
+ ];
+ }
+}
diff --git a/app/Services/Admin/Log/MemberLoginLogService.php b/app/Services/Admin/Log/MemberLoginLogService.php
new file mode 100644
index 0000000..750e7bf
--- /dev/null
+++ b/app/Services/Admin/Log/MemberLoginLogService.php
@@ -0,0 +1,129 @@
+availableYears(2018, $currentYear);
+
+ $year = (int)($query['year'] ?? $currentYear);
+ if (!in_array($year, $years, true)) {
+ // 테이블 없거나 허용 범위 밖이면 최신년도 fallback
+ $year = !empty($years) ? max($years) : $currentYear;
+ }
+
+ $filters = [
+ 'year' => $year,
+ 'date_from' => $this->safeDate($query['date_from'] ?? ''),
+ 'date_to' => $this->safeDate($query['date_to'] ?? ''),
+
+ 'mem_no' => $this->safeInt($query['mem_no'] ?? null),
+
+ 'sf' => $this->safeEnum($query['sf'] ?? '', ['s','f']), // 성공/실패
+ 'conn' => $this->safeEnum($query['conn'] ?? '', ['1','2']), // pc/mobile
+
+ 'ip4' => $this->safeIpPrefix($query['ip4'] ?? ''),
+ 'ip4_c' => $this->safeIpPrefix($query['ip4_c'] ?? ''),
+
+ 'error_code'=> $this->safeStr($query['error_code'] ?? '', 10),
+
+ 'platform' => $this->safeStr($query['platform'] ?? '', 30),
+ 'browser' => $this->safeStr($query['browser'] ?? '', 40),
+ ];
+
+ // 기간 역전 방지
+ if ($filters['date_from'] && $filters['date_to']) {
+ if (strcmp($filters['date_from'], $filters['date_to']) > 0) {
+ [$filters['date_from'], $filters['date_to']] = [$filters['date_to'], $filters['date_from']];
+ }
+ }
+
+ $page = $this->repo->paginateByYear($year, $filters, 30);
+
+ // 화면 가공(라벨/링크 등)
+ $items = [];
+ foreach ($page->items() as $it) {
+ $r = is_array($it) ? $it : (array)$it;
+
+ $sf = (string)($r['sf'] ?? 's');
+ $conn = (string)($r['conn'] ?? '1');
+
+ $items[] = array_merge($r, [
+ 'sf_label' => $sf === 'f' ? '실패' : '성공',
+ 'sf_badge' => $sf === 'f' ? 'badge--bad' : 'badge--ok',
+
+ 'conn_label' => $conn === '2' ? 'M' : 'PC',
+ 'conn_badge' => $conn === '2' ? 'badge--mvno' : 'badge--muted',
+
+ 'mem_no_int' => (int)($r['mem_no'] ?? 0),
+ 'mem_link' => ((int)($r['mem_no'] ?? 0) > 0) ? ('/members/' . (int)$r['mem_no']) : null,
+
+ // 실패가 아니면 에러코드 화면에서 비워도 됨(뷰에서 처리)
+ ]);
+ }
+
+ return [
+ 'years' => $years,
+ 'filters' => $filters,
+ 'page' => $page,
+ 'items' => $items,
+ ];
+ }
+
+ private function availableYears(int $from, int $to): array
+ {
+ $out = [];
+ for ($y = $from; $y <= $to; $y++) {
+ $t = 'mem_login_' . $y;
+ if (Schema::hasTable($t)) $out[] = $y;
+ }
+ // 없으면 그냥 범위라도 반환(운영/개발에서 초기 셋업 대비)
+ return !empty($out) ? $out : [$to];
+ }
+
+ private function safeStr(mixed $v, int $max): string
+ {
+ $s = trim((string)$v);
+ if ($s === '') return '';
+ if (mb_strlen($s) > $max) $s = mb_substr($s, 0, $max);
+ return $s;
+ }
+
+ private function safeEnum(mixed $v, array $allowed): string
+ {
+ $s = trim((string)$v);
+ return in_array($s, $allowed, true) ? $s : '';
+ }
+
+ private function safeInt(mixed $v): ?int
+ {
+ if ($v === null || $v === '') return null;
+ if (!is_numeric($v)) return null;
+ $n = (int)$v;
+ return $n >= 0 ? $n : null;
+ }
+
+ private function safeDate(mixed $v): ?string
+ {
+ $s = trim((string)$v);
+ if ($s === '') return null;
+ return preg_match('/^\d{4}-\d{2}-\d{2}$/', $s) ? $s : null;
+ }
+
+ private function safeIpPrefix(mixed $v): string
+ {
+ $s = trim((string)$v);
+ if ($s === '') return '';
+ return preg_match('/^[0-9.]+$/', $s) ? $s : '';
+ }
+}
diff --git a/app/Services/Admin/Log/MemberPasswdModifyLogService.php b/app/Services/Admin/Log/MemberPasswdModifyLogService.php
new file mode 100644
index 0000000..8e16a69
--- /dev/null
+++ b/app/Services/Admin/Log/MemberPasswdModifyLogService.php
@@ -0,0 +1,162 @@
+ $this->safeDate($query['date_from'] ?? ''),
+ 'date_to' => $this->safeDate($query['date_to'] ?? ''),
+
+ 'state' => $this->safeEnum($query['state'] ?? '', ['S','E']), // S 직접, E 찾기
+ 'mem_no' => $this->safeInt($query['mem_no'] ?? null),
+
+ 'type' => $this->safeStr($query['type'] ?? '', 40), // 신포맷 type
+ 'email' => $this->safeStr($query['email'] ?? '', 80), // 구포맷 user_email
+ 'ip' => $this->safeIpPrefix($query['ip'] ?? ''), // remote_addr or auth_key contains
+ 'q' => $this->safeStr($query['q'] ?? '', 120), // info 전체 like
+ ];
+
+ if ($filters['date_from'] && $filters['date_to']) {
+ if (strcmp($filters['date_from'], $filters['date_to']) > 0) {
+ [$filters['date_from'], $filters['date_to']] = [$filters['date_to'], $filters['date_from']];
+ }
+ }
+
+ $page = $this->repo->paginate($filters, 30);
+
+ $items = [];
+ foreach ($page->items() as $it) {
+ $r = is_array($it) ? $it : (array)$it;
+
+ $infoRaw = (string)($r['info'] ?? '');
+ $infoArr = $this->decodeJson($infoRaw);
+
+ $state = (string)($r['state'] ?? '');
+ $memNo = (int)($infoArr['mem_no'] ?? 0);
+
+ $type = trim((string)($infoArr['type'] ?? ''));
+ $eventType = $type !== '' ? $type : ($state === 'E' ? '비밀번호찾기 변경' : '비밀번호변경');
+
+ $eventTime = trim((string)($infoArr['redate'] ?? ''));
+ if ($eventTime === '') $eventTime = (string)($r['rgdate'] ?? '');
+
+ $ip = trim((string)($infoArr['remote_addr'] ?? ''));
+ if ($ip === '') {
+ $ip = $this->extractIpFromAuthKey((string)($infoArr['auth_key'] ?? '')) ?: '';
+ }
+
+ $email = trim((string)($infoArr['user_email'] ?? ''));
+ if ($email === '') $email = trim((string)($infoArr['email'] ?? ''));
+
+ $agent = trim((string)($infoArr['agent'] ?? ''));
+
+ $items[] = array_merge($r, [
+ 'info_arr' => $infoArr,
+
+ 'mem_no_int' => $memNo,
+ 'mem_link' => $memNo > 0 ? ('/members/' . $memNo) : null,
+
+ 'state_label' => $state === 'E' ? '비번찾기' : '직접변경',
+ 'state_badge' => $state === 'E' ? 'badge--warn' : 'badge--ok',
+
+ 'event_type' => $eventType,
+ 'event_time' => $eventTime,
+
+ 'ip_norm' => $ip,
+ 'email_norm' => $email,
+ 'agent_norm' => $agent,
+
+ // 펼침용(구포맷 핵심 필드)
+ 'auth_key' => (string)($infoArr['auth_key'] ?? ''),
+ 'auth_effective_time' => (string)($infoArr['auth_effective_time'] ?? ''),
+
+ // raw pretty
+ 'info_pretty' => $this->prettyJson($infoArr, $infoRaw),
+ ]);
+ }
+
+ return [
+ 'filters' => $filters,
+ 'page' => $page,
+ 'items' => $items,
+
+ 'stateMap' => [
+ 'S' => '직접변경',
+ 'E' => '비번찾기',
+ ],
+ ];
+ }
+
+ private function decodeJson(string $raw): array
+ {
+ $raw = trim($raw);
+ if ($raw === '') return [];
+ $arr = json_decode($raw, true);
+ return is_array($arr) ? $arr : [];
+ }
+
+ private function prettyJson(array $arr, string $fallbackRaw): string
+ {
+ if (!empty($arr)) {
+ return (string)json_encode($arr, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ }
+ return $fallbackRaw;
+ }
+
+ private function extractIpFromAuthKey(string $authKey): ?string
+ {
+ $authKey = trim($authKey);
+ if ($authKey === '') return null;
+
+ // auth_key 끝에 "1.85.101.111" 같은 IP가 붙는 케이스 대응 (마지막 매치 사용)
+ if (preg_match_all('/(\d{1,3}(?:\.\d{1,3}){3})/', $authKey, $m) && !empty($m[1])) {
+ return (string)end($m[1]);
+ }
+ return null;
+ }
+
+ private function safeStr(mixed $v, int $max): string
+ {
+ $s = trim((string)$v);
+ if ($s === '') return '';
+ if (mb_strlen($s) > $max) $s = mb_substr($s, 0, $max);
+ return $s;
+ }
+
+ private function safeEnum(mixed $v, array $allowed): string
+ {
+ $s = trim((string)$v);
+ return in_array($s, $allowed, true) ? $s : '';
+ }
+
+ private function safeInt(mixed $v): ?int
+ {
+ if ($v === null || $v === '') return null;
+ if (!is_numeric($v)) return null;
+ $n = (int)$v;
+ return $n >= 0 ? $n : null;
+ }
+
+ private function safeDate(mixed $v): ?string
+ {
+ $s = trim((string)$v);
+ if ($s === '') return null;
+ return preg_match('/^\d{4}-\d{2}-\d{2}$/', $s) ? $s : null;
+ }
+
+ private function safeIpPrefix(mixed $v): string
+ {
+ $s = trim((string)$v);
+ if ($s === '') return '';
+ return preg_match('/^[0-9.]+$/', $s) ? $s : '';
+ }
+}
diff --git a/app/Services/FindPasswordService.php b/app/Services/FindPasswordService.php
index c6540b6..d81a557 100644
--- a/app/Services/FindPasswordService.php
+++ b/app/Services/FindPasswordService.php
@@ -228,6 +228,7 @@ class FindPasswordService
$this->members->updatePasswordOnly($memNo, $newPassword);
$this->members->logPasswordResetSuccess(
$memNo,
+ $member->email,
(string) $request->ip(),
(string) $request->userAgent(),
'E'
diff --git a/docs/product_table/pfy_phase1_schema.md b/docs/product_table/pfy_phase1_schema.md
new file mode 100644
index 0000000..30f847c
--- /dev/null
+++ b/docs/product_table/pfy_phase1_schema.md
@@ -0,0 +1,306 @@
+# Phase 1 DB 스키마 (상품등록/전시 + SKU + 판매채널 + 결제수단 + 자사핀재고 + 이미지)
+
+- 신규 테이블은 모두 `pfy_` 접두사 사용
+- MariaDB (InnoDB, `utf8mb4`) 기준
+- 레거시/기존 회원/관리자 테이블과 FK는 **1단계에서는 걸지 않음(컬럼만 준비)**
+ → 2단계(주문/결제)부터 FK/연동 강화
+
+---
+
+## 1) `pfy_categories` : 1차/2차 카테고리 트리
+
+- `parent_id` 로 1차/2차 구성
+- `sort` / `is_active` 로 전시 제어
+
+```sql
+CREATE TABLE IF NOT EXISTS pfy_categories (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'PK',
+ parent_id BIGINT UNSIGNED NULL COMMENT '상위 카테고리 ID (1차는 NULL, 2차는 1차의 id)',
+ name VARCHAR(100) NOT NULL COMMENT '카테고리명',
+ slug VARCHAR(120) NOT NULL COMMENT 'URL/식별용 슬러그(유니크 권장)',
+ sort INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '정렬값(작을수록 먼저)',
+ is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '노출 여부(1=노출,0=숨김)',
+ icon_path VARCHAR(255) NULL COMMENT '아이콘 경로(선택)',
+ banner_path VARCHAR(255) NULL COMMENT '배너 경로(선택)',
+ desc_short VARCHAR(255) NULL COMMENT '카테고리 짧은 설명(선택)',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_pfy_categories_slug (slug),
+ KEY idx_pfy_categories_parent_sort (parent_id, sort)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+ COMMENT='[PFY] 카테고리(1차/2차 트리)';
+```
+
+---
+
+## 2) `pfy_media_files` : 업로드된 파일(상품 이미지 등) 메타데이터
+
+- 업로드된 이미지를 “선택”해서 상품에 연결하는 용도
+
+```sql
+CREATE TABLE IF NOT EXISTS pfy_media_files (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'PK',
+ kind ENUM('image','file') NOT NULL DEFAULT 'image' COMMENT '파일 종류',
+ disk VARCHAR(40) NOT NULL DEFAULT 'public' COMMENT '스토리지 디스크명(Laravel disks)',
+ path VARCHAR(500) NOT NULL COMMENT '스토리지 상대 경로',
+ original_name VARCHAR(255) NULL COMMENT '원본 파일명',
+ mime VARCHAR(120) NULL COMMENT 'MIME 타입',
+ size_bytes BIGINT UNSIGNED NULL COMMENT '파일 크기(bytes)',
+ width INT UNSIGNED NULL COMMENT '이미지 폭(px, 이미지인 경우)',
+ height INT UNSIGNED NULL COMMENT '이미지 높이(px, 이미지인 경우)',
+ checksum_sha256 CHAR(64) NULL COMMENT '파일 무결성 체크섬(선택)',
+ uploaded_by_admin_id BIGINT UNSIGNED NULL COMMENT '업로드 관리자 ID(레거시/기존 admin_users 연동 예정)',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
+ PRIMARY KEY (id),
+ KEY idx_pfy_media_files_kind (kind),
+ KEY idx_pfy_media_files_created (created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+ COMMENT='[PFY] 업로드 파일 메타(상품이미지 선택/재사용)';
+```
+
+---
+
+## 3) `pfy_products` : 상품(전시 단위)
+
+- 상품명/타입/판매기간/상태/대표이미지/매입가능 여부
+
+```sql
+CREATE TABLE IF NOT EXISTS pfy_products (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'PK',
+ category_id BIGINT UNSIGNED NOT NULL COMMENT '2차 카테고리 ID(pfy_categories.id)',
+ name VARCHAR(160) NOT NULL COMMENT '상품명(수기)',
+ type ENUM('online','delivery') NOT NULL DEFAULT 'online' COMMENT '상품타입(온라인/배송)',
+ is_buyback_allowed ENUM('Y','N') NOT NULL DEFAULT 'Y' COMMENT '매입가능여부(Y=가능,N=불가)',
+ sale_period_type ENUM('always','ranged') NOT NULL DEFAULT 'always' COMMENT '판매기간 타입(상시/기간설정)',
+ sale_start_at DATETIME NULL COMMENT '판매 시작일(기간설정일 때)',
+ sale_end_at DATETIME NULL COMMENT '판매 종료일(기간설정일 때)',
+ status ENUM('active','hidden','soldout') NOT NULL DEFAULT 'active' COMMENT '노출상태(노출/숨김/품절)',
+ main_image_id BIGINT UNSIGNED NULL COMMENT '대표 이미지(pfy_media_files.id)',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
+ PRIMARY KEY (id),
+ KEY idx_pfy_products_category (category_id),
+ KEY idx_pfy_products_status (status),
+ KEY idx_pfy_products_sale_period (sale_period_type, sale_start_at, sale_end_at),
+ CONSTRAINT fk_pfy_products_category
+ FOREIGN KEY (category_id) REFERENCES pfy_categories(id)
+ ON UPDATE CASCADE ON DELETE RESTRICT,
+ CONSTRAINT fk_pfy_products_main_image
+ FOREIGN KEY (main_image_id) REFERENCES pfy_media_files(id)
+ ON UPDATE CASCADE ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+ COMMENT='[PFY] 상품(전시 단위)';
+```
+
+---
+
+## 4) `pfy_product_contents` : 상품 상세 에디터 3종(1:1)
+
+- 상세설명/이용안내/주의사항 HTML 저장
+
+```sql
+CREATE TABLE IF NOT EXISTS pfy_product_contents (
+ product_id BIGINT UNSIGNED NOT NULL COMMENT 'PK & FK(pfy_products.id)',
+ detail_html LONGTEXT NULL COMMENT '상세설명(HTML)',
+ guide_html LONGTEXT NULL COMMENT '이용안내(HTML)',
+ caution_html LONGTEXT NULL COMMENT '주의사항(HTML)',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
+ PRIMARY KEY (product_id),
+ CONSTRAINT fk_pfy_product_contents_product
+ FOREIGN KEY (product_id) REFERENCES pfy_products(id)
+ ON UPDATE CASCADE ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+ COMMENT='[PFY] 상품 에디터 컨텐츠(상세/안내/주의)';
+```
+
+---
+
+## 5) `pfy_product_skus` : 권종/가격 단위(SKU)
+
+- 정상가/할인율/판매가(스냅샷 계산 저장 추천)
+- 재고는 `stock_mode` 로 (연동판매는 `infinite`, 자사핀은 `limited`)
+
+```sql
+CREATE TABLE IF NOT EXISTS pfy_product_skus (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'PK',
+ product_id BIGINT UNSIGNED NOT NULL COMMENT '상품 ID(pfy_products.id)',
+ denomination INT UNSIGNED NOT NULL COMMENT '권종 금액(예: 10000, 50000)',
+ normal_price INT UNSIGNED NOT NULL COMMENT '정상가(원)',
+ discount_rate DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT '할인율(%)',
+ sale_price INT UNSIGNED NOT NULL COMMENT '판매가(원) - 운영/정산 안정 위해 계산 후 저장 권장',
+ stock_mode ENUM('infinite','limited') NOT NULL DEFAULT 'infinite' COMMENT '재고방식(무한/한정)',
+ is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '사용여부(1=사용,0=중지)',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
+ PRIMARY KEY (id),
+ KEY idx_pfy_skus_product (product_id),
+ KEY idx_pfy_skus_active (product_id, is_active),
+ KEY idx_pfy_skus_denomination (product_id, denomination),
+ CONSTRAINT fk_pfy_skus_product
+ FOREIGN KEY (product_id) REFERENCES pfy_products(id)
+ ON UPDATE CASCADE ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+ COMMENT='[PFY] 상품 SKU(권종/가격 단위)';
+```
+
+---
+
+## 6) `pfy_sku_sale_channels` : SKU 판매방식/연동채널 (기본: 1 SKU = 1 판매채널)
+
+### `sale_mode`
+- `SELF_PIN` : 자사핀 재고 판매
+- `VENDOR_API_SHOW` : 연동발행 후 우리 사이트에서 핀 표시
+- `VENDOR_SMS` : 업체가 SMS로 즉시 발송(핀 저장 최소화 가능)
+
+### `vendor`
+- `DANAL` / `KORCULTURE` / `KPREPAID` (필요 시 확장)
+
+```sql
+CREATE TABLE IF NOT EXISTS pfy_sku_sale_channels (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'PK',
+ sku_id BIGINT UNSIGNED NOT NULL COMMENT 'SKU ID(pfy_product_skus.id)',
+ sale_mode ENUM('SELF_PIN','VENDOR_API_SHOW','VENDOR_SMS') NOT NULL COMMENT '판매모드',
+ vendor ENUM('NONE','DANAL','KORCULTURE','KPREPAID') NOT NULL DEFAULT 'NONE' COMMENT '연동업체(없으면 NONE)',
+ vendor_product_code VARCHAR(40) NULL COMMENT '업체 상품코드(예: DANAL CULTURE, KPREPAID 1231 등)',
+ pg ENUM('NONE','DANAL') NOT NULL DEFAULT 'DANAL' COMMENT '결제 PG(현재 DANAL, 추후 확장)',
+ is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '사용여부(1=사용,0=중지)',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_pfy_sale_channels_sku (sku_id),
+ KEY idx_pfy_sale_channels_vendor (vendor, vendor_product_code),
+ CONSTRAINT fk_pfy_sale_channels_sku
+ FOREIGN KEY (sku_id) REFERENCES pfy_product_skus(id)
+ ON UPDATE CASCADE ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+ COMMENT='[PFY] SKU 판매채널/연동 설정';
+```
+
+---
+
+## 7) `pfy_payment_methods` : 결제수단 마스터
+
+- `code` 를 PK로 사용(안정적, pivot에 쓰기 편함)
+
+```sql
+CREATE TABLE IF NOT EXISTS pfy_payment_methods (
+ code VARCHAR(30) NOT NULL COMMENT '결제수단 코드(PK)',
+ label VARCHAR(60) NOT NULL COMMENT '표시명(관리자/회원)',
+ is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '사용여부',
+ sort INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '노출 정렬',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
+ PRIMARY KEY (code),
+ KEY idx_pfy_payment_methods_active (is_active, sort)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+ COMMENT='[PFY] 결제수단 마스터';
+```
+
+---
+
+## 8) `pfy_sku_payment_methods` : SKU별 허용 결제수단(pivot)
+
+- SKU마다 가능한 결제수단 체크박스 용도
+
+```sql
+CREATE TABLE IF NOT EXISTS pfy_sku_payment_methods (
+ sku_id BIGINT UNSIGNED NOT NULL COMMENT 'SKU ID(pfy_product_skus.id)',
+ payment_method_code VARCHAR(30) NOT NULL COMMENT '결제수단 코드(pfy_payment_methods.code)',
+ is_enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '허용 여부(1=허용,0=비허용)',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
+ PRIMARY KEY (sku_id, payment_method_code),
+ KEY idx_pfy_sku_pm_method (payment_method_code, is_enabled),
+ CONSTRAINT fk_pfy_sku_pm_sku
+ FOREIGN KEY (sku_id) REFERENCES pfy_product_skus(id)
+ ON UPDATE CASCADE ON DELETE CASCADE,
+ CONSTRAINT fk_pfy_sku_pm_method
+ FOREIGN KEY (payment_method_code) REFERENCES pfy_payment_methods(code)
+ ON UPDATE CASCADE ON DELETE RESTRICT
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+ COMMENT='[PFY] SKU별 허용 결제수단 매핑';
+```
+
+---
+
+## 9) `pfy_pin_batches` : 자사핀 입력 작업 단위(업로드/수기 등록)
+
+```sql
+CREATE TABLE IF NOT EXISTS pfy_pin_batches (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'PK',
+ sku_id BIGINT UNSIGNED NOT NULL COMMENT 'SKU ID(pfy_product_skus.id)',
+ source_type ENUM('upload','manual') NOT NULL COMMENT '핀 등록 방식(파일/수기)',
+ original_filename VARCHAR(255) NULL COMMENT '업로드 파일명(업로드일 때)',
+ total_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '배치 총 핀 개수',
+ uploaded_by_admin_id BIGINT UNSIGNED NULL COMMENT '업로드 관리자 ID(레거시/기존 admin_users 연동 예정)',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
+ PRIMARY KEY (id),
+ KEY idx_pfy_pin_batches_sku (sku_id, created_at),
+ CONSTRAINT fk_pfy_pin_batches_sku
+ FOREIGN KEY (sku_id) REFERENCES pfy_product_skus(id)
+ ON UPDATE CASCADE ON DELETE RESTRICT
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+ COMMENT='[PFY] 자사핀 입력 배치(업로드/수기)';
+```
+
+---
+
+## 10) `pfy_pin_items` : 핀 1건 단위 재고(암호화 저장)
+
+- `pin_enc` : 쌍방향 암호화 저장값(복호화는 권한 통과한 구매자만)
+- `pin_fingerprint` : 중복핀 방지(유니크)
+
+### `status`
+- `available` : 사용가능(재고)
+- `reserved` : 결제 대기/재고 예약
+- `sold` : 판매 완료(주문 라인에 귀속될 예정)
+- `revoked` : 폐기/무효
+- `recalled` : 회수(미사용 회수 프로세스에서 사용)
+
+> 1단계에서는 주문 테이블이 없으므로 `reserved_order_id`, `sold_order_item_id` 는 FK 없이 컬럼만 준비
+
+```sql
+CREATE TABLE IF NOT EXISTS pfy_pin_items (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'PK',
+ batch_id BIGINT UNSIGNED NOT NULL COMMENT '배치 ID(pfy_pin_batches.id)',
+ sku_id BIGINT UNSIGNED NOT NULL COMMENT 'SKU ID(pfy_product_skus.id)',
+ pin_enc TEXT NOT NULL COMMENT '핀 암호문(쌍방향 암호화 결과)',
+ pin_fingerprint CHAR(64) NOT NULL COMMENT '중복 방지 해시(sha256 등) - UNIQUE',
+ status ENUM('available','reserved','sold','revoked','recalled') NOT NULL DEFAULT 'available' COMMENT '재고 상태',
+ reserved_order_id BIGINT UNSIGNED NULL COMMENT '예약된 주문 ID(2단계에서 orders FK 예정)',
+ reserved_until DATETIME NULL COMMENT '예약 만료 시각(스케줄러로 자동 해제)',
+ sold_order_item_id BIGINT UNSIGNED NULL COMMENT '판매된 주문아이템 ID(2단계에서 order_items FK 예정)',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_pfy_pin_items_fingerprint (pin_fingerprint),
+ KEY idx_pfy_pin_items_sku_status (sku_id, status),
+ KEY idx_pfy_pin_items_batch (batch_id),
+ KEY idx_pfy_pin_items_reserved_until (status, reserved_until),
+ CONSTRAINT fk_pfy_pin_items_batch
+ FOREIGN KEY (batch_id) REFERENCES pfy_pin_batches(id)
+ ON UPDATE CASCADE ON DELETE RESTRICT,
+ CONSTRAINT fk_pfy_pin_items_sku
+ FOREIGN KEY (sku_id) REFERENCES pfy_product_skus(id)
+ ON UPDATE CASCADE ON DELETE RESTRICT
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+ COMMENT='[PFY] 자사핀 재고(암호화 저장)';
+```
+
+---
+
+## (선택) 결제수단 초기 데이터 예시
+
+```sql
+INSERT INTO pfy_payment_methods(code,label,sort) VALUES
+('CARD_GENERAL','신용카드(일반)',10),
+('CARD_CASHBACK','신용카드(환급성)',11),
+('MOBILE','휴대폰',20),
+('VBANK','무통장입금',30),
+('KBANKPAY','케이뱅크페이',40),
+('PAYCOIN','페이코인',50);
+```
diff --git a/resources/views/admin/log/AdminAuditLogController.blade.php b/resources/views/admin/log/AdminAuditLogController.blade.php
new file mode 100644
index 0000000..8cce496
--- /dev/null
+++ b/resources/views/admin/log/AdminAuditLogController.blade.php
@@ -0,0 +1,281 @@
+{{-- resources/views/admin/log/AdminAuditLogController.blade.php --}}
+@extends('admin.layouts.app')
+
+@section('title', '관리자 감사 로그')
+@section('page_title', '관리자 감사 로그')
+@section('page_desc', '관리자 행위 감사로그를 조회합니다. (상세는 모달)')
+@section('content_class', 'a-content--full')
+
+@push('head')
+
+@endpush
+
+@section('content')
+ @php
+ $indexUrl = route('admin.systemlog.admin-audit-logs', [], false);
+ $showTpl = route('admin.systemlog.admin-audit-logs.show', ['id' => '__ID__'], false);
+
+ $f = $filters ?? [];
+ $dateFrom = (string)($f['date_from'] ?? '');
+ $dateTo = (string)($f['date_to'] ?? '');
+ $actorQ = (string)($f['actor_q'] ?? '');
+ $action = (string)($f['action'] ?? '');
+ $tt = (string)($f['target_type'] ?? '');
+ $ip = (string)($f['ip'] ?? '');
+ @endphp
+
+
+
+
+
관리자 감사 로그
+
검색/페이징 지원 · 상세는 AJAX 모달
+
+
+
+
+
+
+
+
총 {{ $page->total() }}건
+
+
+
+
+
+ | ID |
+ Actor |
+ Action |
+ Target Type |
+ IP |
+ Created |
+ 상세 |
+
+
+
+ @forelse(($items ?? []) as $r0)
+ @php
+ $r = is_array($r0) ? $r0 : (array)$r0;
+ $id = (int)($r['id'] ?? 0);
+ $aEmail = (string)($r['actor_email'] ?? '');
+ $aName = (string)($r['actor_name'] ?? '');
+ $actorTxt = trim(($aName !== '' ? $aName : '-') . ($aEmail !== '' ? " ({$aEmail})" : ''));
+ @endphp
+
+ | {{ $id }} |
+
+ {{ $aName !== '' ? $aName : '-' }}
+ {{ $aEmail !== '' ? $aEmail : 'admin_users 미조회' }}
+ |
+ {{ $r['action'] ?? '-' }} |
+ {{ $r['target_type'] ?? '-' }} |
+ {{ $r['ip'] ?? '-' }} |
+ {{ $r['created_at'] ?? '-' }} |
+
+
+ |
+
+ @empty
+ | 데이터가 없습니다. |
+ @endforelse
+
+
+
+
+
+ {{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
+
+
+
+ @include('admin.log._audit_log_modal')
+
+
+@endsection
diff --git a/resources/views/admin/log/MemberAccountLogController.blade.php b/resources/views/admin/log/MemberAccountLogController.blade.php
new file mode 100644
index 0000000..59c7d3b
--- /dev/null
+++ b/resources/views/admin/log/MemberAccountLogController.blade.php
@@ -0,0 +1,380 @@
+@extends('admin.layouts.app')
+
+@section('title', '계좌 성명인증 로그')
+@section('page_title', '계좌 성명인증 로그')
+@section('page_desc', '더즌 성명인증 요청/결과 로그를 조회합니다.')
+@section('content_class', 'a-content--full')
+
+@push('head')
+
+@endpush
+
+@section('content')
+ @php
+ $indexUrl = route('admin.systemlog.member-account-logs', [], false);
+
+ $f = $filters ?? [];
+ $dateFrom = (string)($f['date_from'] ?? '');
+ $dateTo = (string)($f['date_to'] ?? '');
+ $memNo = (string)($f['mem_no'] ?? '');
+ $bank = (string)($f['bank_code'] ?? '');
+ $status = (string)($f['status'] ?? '');
+ $account = (string)($f['account'] ?? '');
+ $name = (string)($f['name'] ?? '');
+ $q = (string)($f['q'] ?? '');
+ @endphp
+
+
+
+
+
계좌 성명인증 로그
+
요청/결과 JSON은 모달로 표시 (row 높이 고정)
+
+
+
+
+
+
+
+
총 {{ $page->total() }}건
+
+
+
+
+
+ | SEQ |
+ request_time |
+ result_time |
+ mem_no |
+ bank |
+ account |
+ name |
+ protype |
+ status |
+ depositor/error |
+ JSON |
+
+
+
+
+ @forelse(($items ?? []) as $r0)
+ @php
+ $r = is_array($r0) ? $r0 : (array)$r0;
+ $seq = (int)($r['seq'] ?? 0);
+
+ $memNoInt = (int)($r['mem_no'] ?? 0);
+ $memLink = $r['mem_link'] ?? null;
+
+ $bankCode = (string)($r['bank_code'] ?? '');
+ $acctMask = (string)($r['account_masked'] ?? '');
+ $nameV = (string)($r['mam_accountname'] ?? '');
+ $proType = (string)($r['account_protype'] ?? '');
+
+ $statusInt = (int)($r['status_int'] ?? 0);
+ $badge = (string)($r['status_badge'] ?? 'badge--bad');
+ $label = (string)($r['status_label'] ?? '');
+
+ $depositor = (string)($r['depositor'] ?? '');
+ $errCode = (string)($r['error_code'] ?? '');
+ $errMsg = (string)($r['error_message'] ?? '');
+ @endphp
+
+
+ | {{ $seq }} |
+ {{ $r['request_time'] ?? '-' }} |
+ {{ $r['result_time'] ?? '-' }} |
+
+
+ @if($memNoInt > 0 && $memLink)
+ {{ $memNoInt }}
+ @else
+ -
+ @endif
+ |
+
+
+ @if($bankCode !== '')
+ {{ $bankCode }}
+ @else
+ -
+ @endif
+ |
+
+
+ @if($acctMask !== '')
+ {{ $acctMask }}
+ @else
+ -
+ @endif
+ |
+
+
+ @if($nameV !== '')
+ {{ $nameV }}
+ @else
+ -
+ @endif
+ |
+
+
+ @if($proType !== '')
+ {{ $proType }}
+ @else
+ -
+ @endif
+ |
+
+
+
+ {{ $label }} ({{ $statusInt }})
+
+ |
+
+
+ @if($depositor !== '')
+ {{ $depositor }}
+ @elseif($errCode !== '' || $errMsg !== '')
+ {{ trim($errCode.' '.$errMsg) }}
+ @else
+ -
+ @endif
+ |
+
+
+
+
+
+
+ |
+
+ @empty
+ | 데이터가 없습니다. |
+ @endforelse
+
+
+
+
+
+ {{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
+
+
+
+ {{-- JSON Modal --}}
+
+
+
+
+
계좌 성명인증 JSON
+
요청/결과 JSON
+
+
+
+
+
+
+
+
+
+
+
+@endsection
diff --git a/resources/views/admin/log/MemberJoinLogController.blade.php b/resources/views/admin/log/MemberJoinLogController.blade.php
new file mode 100644
index 0000000..d67be1c
--- /dev/null
+++ b/resources/views/admin/log/MemberJoinLogController.blade.php
@@ -0,0 +1,188 @@
+{{-- resources/views/admin/log/MemberJoinLogController.blade.php --}}
+@extends('admin.layouts.app')
+
+@section('title', '회원가입 필터 로그')
+@section('page_title', '회원가입 필터 로그')
+@section('page_desc', '회원가입 시 필터에 걸린 기록을 조회합니다.')
+@section('content_class', 'a-content--full')
+
+@push('head')
+
+@endpush
+
+@section('content')
+ @php
+ $indexUrl = route('admin.systemlog.member-join-logs', [], false);
+
+ $f = $filters ?? [];
+
+ $dateFrom = (string)($f['date_from'] ?? '');
+ $dateTo = (string)($f['date_to'] ?? '');
+ $gubun = (string)($f['gubun'] ?? '');
+ $memNo = (string)($f['mem_no'] ?? '');
+ $phone = (string)($f['phone'] ?? '');
+ $email = (string)($f['email'] ?? '');
+ $ip4 = (string)($f['ip4'] ?? '');
+ $ip4c = (string)($f['ip4_c'] ?? '');
+ @endphp
+
+
+
+
+
회원가입 필터 로그
+
리스트에서 전체 정보 표시 · 검색/페이징 지원
+
+
+
+
+
+
+
+
총 {{ $page->total() }}건
+
+
+
+
+
+ | SEQ |
+ 일시 |
+ 회원 |
+ 전화/이메일 |
+ IP |
+ gubun |
+
+
+
+ @forelse(($items ?? []) as $r0)
+ @php
+ $r = is_array($r0) ? $r0 : (array)$r0;
+ $seq = (int)($r['seq'] ?? 0);
+
+ $memNoInt = (int)($r['mem_no_int'] ?? 0);
+ $memLink = $r['mem_link'] ?? null;
+
+ $emailDisp = (string)($r['email_display'] ?? '');
+ if ($emailDisp === '-' ) $emailDisp = '';
+
+ $ip4v = (string)($r['ip4'] ?? '');
+ $ip4cv = (string)($r['ip4_c'] ?? '');
+ @endphp
+
+ | {{ $seq }} |
+ {{ $r['dt_reg'] ?? '-' }} |
+
+
+ @if($memNoInt > 0 && $memLink)
+ {{ $memNoInt }}
+ @else
+ 가입차단
+ @endif
+ |
+
+ {{-- ✅ 전화/이메일 한줄: "전화번호 바로뒤 이메일" (이메일 없으면 빈값) --}}
+
+
+ {{ $r['corp_label'] ?? '-' }}
+ {{ $r['phone_display'] ?? '-' }}
+ @if($emailDisp !== '')
+ {{ $emailDisp }}
+ @endif
+
+ |
+
+ {{-- ✅ IP 한줄: ip4 + ip4_c --}}
+
+ {{ $ip4v !== '' ? $ip4v : '-' }}
+ @if($ip4cv !== '')
+ {{ $ip4cv }}
+ @endif
+ |
+
+ {{ $r['gubun'] ?? '-' }} |
+
+ @empty
+ | 데이터가 없습니다. |
+ @endforelse
+
+
+
+
+
+ {{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
+
+
+@endsection
diff --git a/resources/views/admin/log/MemberLoginLogController.blade.php b/resources/views/admin/log/MemberLoginLogController.blade.php
new file mode 100644
index 0000000..e60345d
--- /dev/null
+++ b/resources/views/admin/log/MemberLoginLogController.blade.php
@@ -0,0 +1,246 @@
+{{-- resources/views/admin/log/MemberLoginLogController.blade.php --}}
+@extends('admin.layouts.app')
+
+@section('title', '로그인 로그')
+@section('page_title', '로그인 로그')
+@section('page_desc', '연도별 로그인 이력을 조회합니다.')
+@section('content_class', 'a-content--full')
+
+@push('head')
+
+@endpush
+
+@section('content')
+ @php
+ $indexUrl = route('admin.systemlog.member-login-logs', [], false);
+
+ $f = $filters ?? [];
+ $years = $years ?? [];
+
+ $year = (int)($f['year'] ?? (int)date('Y'));
+ $dateFrom = (string)($f['date_from'] ?? '');
+ $dateTo = (string)($f['date_to'] ?? '');
+
+ $memNo = (string)($f['mem_no'] ?? '');
+ $sf = (string)($f['sf'] ?? '');
+ $conn = (string)($f['conn'] ?? '');
+
+ $ip4 = (string)($f['ip4'] ?? '');
+ $ip4c = (string)($f['ip4_c'] ?? '');
+
+ $err = (string)($f['error_code'] ?? '');
+ $platform = (string)($f['platform'] ?? '');
+ $browser = (string)($f['browser'] ?? '');
+ @endphp
+
+
+
+
+
로그인 로그
+
연도별(mem_login_YYYY) 테이블 조회 · 검색/페이징
+
+
+
+
+
+
+
+
총 {{ $page->total() }}건
+
+
+
+
+
+ | SEQ |
+ 일시 |
+ 회원 |
+ 결과 |
+ 경로 |
+ IP |
+ 플랫폼/브라우저 |
+ 실패코드 |
+
+
+
+ @forelse(($items ?? []) as $r0)
+ @php
+ $r = is_array($r0) ? $r0 : (array)$r0;
+ $seq = (int)($r['seq'] ?? 0);
+
+ $memNoInt = (int)($r['mem_no_int'] ?? 0);
+ $memLink = $r['mem_link'] ?? null;
+
+ $sf = (string)($r['sf'] ?? 's');
+ $errCode = trim((string)($r['error_code'] ?? ''));
+ // 성공이면 실패코드는 비움(요구에 따라 여기서 제어)
+ $errShow = ($sf === 'f' && $errCode !== '') ? $errCode : '';
+
+ $ip4 = (string)($r['ip4'] ?? '');
+ $ip4c = (string)($r['ip4_c'] ?? '');
+
+ $plat = trim((string)($r['platform'] ?? ''));
+ $brow = trim((string)($r['browser'] ?? ''));
+ @endphp
+
+ | {{ $seq }} |
+ {{ $r['dt_reg'] ?? '-' }} |
+
+
+ @if($memNoInt > 0 && $memLink)
+ {{ $memNoInt }}
+ @else
+ 0
+ @endif
+ |
+
+
+ {{ $r['sf_label'] ?? '-' }}
+ |
+
+
+ {{ $r['conn_label'] ?? '-' }}
+ |
+
+
+ {{ $ip4 !== '' ? $ip4 : '-' }}
+ @if($ip4c !== '')
+ {{ $ip4c }}
+ @endif
+ |
+
+
+ {{ $plat !== '' ? $plat : '-' }}
+ {{ $brow !== '' ? $brow : '-' }}
+ |
+
+
+ @if($errShow !== '')
+ {{ $errShow }}
+ @else
+ -
+ @endif
+ |
+
+ @empty
+ | 데이터가 없습니다. |
+ @endforelse
+
+
+
+
+
+ {{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
+
+
+
+
+@endsection
diff --git a/resources/views/admin/log/MemberPasswdModifyLogController.blade.php b/resources/views/admin/log/MemberPasswdModifyLogController.blade.php
new file mode 100644
index 0000000..d52c9f4
--- /dev/null
+++ b/resources/views/admin/log/MemberPasswdModifyLogController.blade.php
@@ -0,0 +1,338 @@
+{{-- resources/views/admin/log/MemberPasswdModifyLogController.blade.php --}}
+@extends('admin.layouts.app')
+
+@section('title', '비밀번호 변경 로그')
+@section('page_title', '비밀번호 변경 로그')
+@section('page_desc', '로그인/2차 비밀번호 변경 및 비밀번호 찾기 변경 이력을 조회합니다.')
+@section('content_class', 'a-content--full')
+
+@push('head')
+
+@endpush
+
+@section('content')
+ @php
+ $indexUrl = route('admin.systemlog.member-passwd-modify-logs', [], false);
+
+ $f = $filters ?? [];
+ $stateMap = $stateMap ?? ['S'=>'직접변경','E'=>'비번찾기'];
+
+ $dateFrom = (string)($f['date_from'] ?? '');
+ $dateTo = (string)($f['date_to'] ?? '');
+ $state = (string)($f['state'] ?? '');
+ $memNo = (string)($f['mem_no'] ?? '');
+ $type = (string)($f['type'] ?? '');
+ $email = (string)($f['email'] ?? '');
+ $ip = (string)($f['ip'] ?? '');
+ $q = (string)($f['q'] ?? '');
+ @endphp
+
+
+
+
+
비밀번호 변경 로그
+
구/신 JSON 혼재 → 핵심 필드 정규화 + 원본 JSON 펼쳐보기
+
+
+
+
+
+
+
+
총 {{ $page->total() }}건
+
+
+
+
+
+ | SEQ |
+ rgdate |
+ state |
+ mem_no |
+ type |
+ ip |
+ email |
+ JSON |
+
+
+
+ @forelse(($items ?? []) as $r0)
+ @php
+ $r = is_array($r0) ? $r0 : (array)$r0;
+ $seq = (int)($r['seq'] ?? 0);
+
+ $memNoInt = (int)($r['mem_no_int'] ?? 0);
+ $memLink = $r['mem_link'] ?? null;
+
+ $agent = (string)($r['agent_norm'] ?? '');
+ $emailN = (string)($r['email_norm'] ?? '');
+ $ipN = (string)($r['ip_norm'] ?? '');
+
+ $authKey = (string)($r['auth_key'] ?? '');
+ $authEff = (string)($r['auth_effective_time'] ?? '');
+ @endphp
+
+ | {{ $seq }} |
+ {{ $r['rgdate'] ?? '-' }} |
+
+
+
+ {{ $r['state_label'] ?? ($stateMap[$r['state'] ?? ''] ?? '-') }}
+
+ |
+
+
+ @if($memNoInt > 0 && $memLink)
+ {{ $memNoInt }}
+ @else
+ -
+ @endif
+ |
+
+ {{ $r['event_type'] ?? '-' }} |
+
+
+ @if($ipN !== '')
+ {{ $ipN }}
+ @else
+ -
+ @endif
+ |
+
+
+ @if($emailN !== '')
+ {{ $emailN }}
+ @else
+ -
+ @endif
+ |
+
+ @php
+ $agent = (string)($r['agent_norm'] ?? '');
+ $authKey = (string)($r['auth_key'] ?? '');
+ $authEff = (string)($r['auth_effective_time'] ?? '');
+ @endphp
+
+
+
+
+ {{-- AJAX 없이: row별 JSON을 숨겨두고 모달에서 꺼내씀 --}}
+
+ |
+
+ @empty
+ | 데이터가 없습니다. |
+ @endforelse
+
+
+
+
+
+ {{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
+
+
+
+
+
+
+@endsection
diff --git a/resources/views/admin/log/_audit_log_modal.blade.php b/resources/views/admin/log/_audit_log_modal.blade.php
new file mode 100644
index 0000000..d776e46
--- /dev/null
+++ b/resources/views/admin/log/_audit_log_modal.blade.php
@@ -0,0 +1,61 @@
+{{-- resources/views/admin/log/_audit_log_modal.blade.php --}}
+
+
+
+
+
감사로그 상세
+
before/after JSON과 user_agent를 확인하세요.
+
+
+
+
+
+
+
+
+
diff --git a/resources/views/admin/log/member_danalauthtel_log.blade.php b/resources/views/admin/log/member_danalauthtel_log.blade.php
new file mode 100644
index 0000000..8eef598
--- /dev/null
+++ b/resources/views/admin/log/member_danalauthtel_log.blade.php
@@ -0,0 +1,235 @@
+@extends('admin.layouts.app')
+
+@section('title', '다날 휴대폰 본인인증 로그')
+@section('page_title', '다날 휴대폰 본인인증 로그')
+@section('page_desc', 'mem_no / 전화번호로 조회 (이름/CI/DI 등 개인정보 미노출)')
+@section('content_class', 'a-content--full')
+
+@push('head')
+
+@endpush
+
+@section('content')
+@php
+$memNo = (string)($filters['mem_no'] ?? '');
+$phone = (string)($filters['phone'] ?? '');
+$indexUrl = route('admin.systemlog.member-danalauthtel-logs', [], false);
+@endphp
+
+
+
+
+
다날 휴대폰 본인인증 로그
+
mem_no / 전화번호만 검색 · 이름/CI/DI 등은 표시하지 않음
+
+
+
+
+
+
+
+
총 {{ $page->total() }}건
+
+
+
+
+
+ | SEQ |
+ 구분 |
+ 결과 |
+ 회원번호 |
+ 전화번호 |
+ TID |
+ 인증시간 |
+ |
+
+
+
+ @forelse(($rows ?? []) as $r)
+ @php
+ $seq = (int)($r['seq'] ?? 0);
+ $g = (string)($r['gubun'] ?? '');
+ $rc = (string)($r['res_code'] ?? '');
+ $mno = (int)($r['mem_no'] ?? 0);
+ $tid = (string)($r['TID'] ?? '');
+ $dt = (string)($r['rgdate'] ?? '');
+
+ $gLabel = ($g === 'J') ? '가입' : (($g === 'M') ? '수정' : '-');
+ $resOk = ($rc === '0000');
+ $resCls = $resOk ? 'pill--ok' : 'pill--bad';
+
+ $phoneDisp = (string)($r['phone_display'] ?? '');
+ $memberUrl = $mno > 0 ? ('/members/'.$mno) : '';
+ @endphp
+
+
+ | {{ $seq }} |
+ {{ $gLabel }} |
+ {{ $rc !== '' ? $rc : '-' }} |
+
+ @if($mno > 0)
+ {{ $mno }}
+ @else
+ 가입차단/미생성
+ @endif
+ |
+
+ @if($phoneDisp !== '')
+ {{ $phoneDisp }}
+ @else
+ -
+ @endif
+ |
+ {{ $tid !== '' ? $tid : '-' }} |
+ {{ $dt !== '' ? $dt : '-' }} |
+
+
+
+ {{-- row별 JSON(모달용) - 안전하게 script에 저장 --}}
+
+ |
+
+ @empty
+ | 데이터가 없습니다. |
+ @endforelse
+
+
+
+
+
+ {{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
+
+
+
+{{-- Modal --}}
+
+
+
+
+
JSON 상세
+
CI/DI/이름/생년월일/성별/이메일은 표시하지 않습니다.
+
+
+
+
+
+
+
+
+
+@endsection
diff --git a/resources/views/admin/partials/sidebar.blade.php b/resources/views/admin/partials/sidebar.blade.php
index 6659184..f96a742 100644
--- a/resources/views/admin/partials/sidebar.blade.php
+++ b/resources/views/admin/partials/sidebar.blade.php
@@ -46,16 +46,35 @@
],
],
[
- 'title' => '상품권 관리',
- 'items' => [
- ['label' => '상품 리스트', 'route' => 'admin.products.index','roles' => ['super_admin','product']],
- ['label' => '상품 등록', 'route' => 'admin.products.create','roles' => ['super_admin','product']],
- ['label' => '판매 코드 관리', 'route' => 'admin.sale-codes.index','roles' => ['super_admin','product']],
- ['label' => '핀 번호 관리', 'route' => 'admin.pins.index','roles' => ['super_admin','product']],
- ['label' => '메인 노출 관리', 'route' => 'admin.exposure.index','roles' => ['super_admin','product']],
- ['label' => '결제 수수료/정책', 'route' => 'admin.fees.index','roles' => ['super_admin','product']],
+ 'title' => '상품 관리',
+ 'items' => [
+ // 1) 기본 데이터(전시 구조)
+ ['label' => '카테고리 관리', 'route' => 'admin.categories.index', 'roles' => ['super_admin','product']], // ✅ 추천(신규)
+
+ // 2) 상품 등록/관리
+ ['label' => '상품 리스트', 'route' => 'admin.products.index', 'roles' => ['super_admin','product']],
+ ['label' => '상품 등록', 'route' => 'admin.products.create', 'roles' => ['super_admin','product']],
+
+ // 3) SKU/가격/권종 (상품 상세에서 같이 관리해도 되지만, 별도 메뉴가 있으면 운영 편함)
+ ['label' => '금액권/가격 관리', 'route' => 'admin.skus.index', 'roles' => ['super_admin','product']], // ✅ 추천(신규)
+
+ // 4) 판매채널/연동코드 (DANAL/KORCULTURE/KPREPAID 코드 매핑)
+ ['label' => '판매 코드 관리', 'route' => 'admin.sale-codes.index', 'roles' => ['super_admin','product']],
+
+ // 5) 자산(이미지) 관리
+ ['label' => '이미지 라이브러리', 'route' => 'admin.media.index', 'roles' => ['super_admin','product']], // ✅ 추천(신규)
+
+ // 6) 자사 핀 재고/회수/추출
+ ['label' => '핀 번호 관리', 'route' => 'admin.pins.index', 'roles' => ['super_admin','product']],
+ // ['label' => '핀 회수/추출', 'route' => 'admin.pins.recalls', 'roles' => ['super_admin','product']], // ✅ 핀 메뉴 내부 탭으로 처리해도 OK
+
+ // 7) 메인 노출/전시
+ ['label' => '메인 노출 관리', 'route' => 'admin.exposure.index', 'roles' => ['super_admin','product']],
+
+ // 8) 결제 정책(상품쪽에서 설정하지만 성격상 “정책”이므로 아래쪽)
+ ['label' => '결제 수수료/정책', 'route' => 'admin.fees.index', 'roles' => ['super_admin','product']],
+ ],
],
- ],
[
'title' => '매입/정산',
'items' => [
@@ -75,11 +94,13 @@
[
'title' => '시스템 로그',
'items' => [
- ['label' => '로그인 로그', 'route' => 'admin.logs.login','roles' => ['super_admin','finance','product','support']],
- ['label' => '다날 인증 로그', 'route' => 'admin.logs.danal','roles' => ['super_admin','finance','product','support']],
- ['label' => '결제 로그', 'route' => 'admin.logs.pay','roles' => ['super_admin','finance','product','support']],
- ['label' => '기타 로그', 'route' => 'admin.logs.misc','roles' => ['super_admin','finance','product','support']],
- ['label' => '관리자 활동 로그', 'route' => 'admin.logs.audit','roles' => ['super_admin','finance','product','support']],
+ ['label' => '다날 결제 로그', 'route' => 'admin.logs.pay','roles' => ['super_admin','finance','product','support']],
+ ['label' => '회원 다날 인증 로그', 'route' => 'admin.systemlog.member-danalauthtel-logs','roles' => ['super_admin']],
+ ['label' => '회원 계좌번호 성명인증 로그', 'route' => 'admin.systemlog.member-account-logs','roles' => ['super_admin']],
+ ['label' => '회원 비밀번호변경 로그', 'route' => 'admin.systemlog.member-passwd-modify-logs','roles' => ['super_admin']],
+ ['label' => '회원 로그인 로그', 'route' => 'admin.systemlog.member-login-logs','roles' => ['super_admin']],
+ ['label' => '회원 가입차단/알림 로그', 'route' => 'admin.systemlog.member-join-logs','roles' => ['super_admin']],
+ ['label' => '관리자 활동 로그', 'route' => 'admin.systemlog.admin-audit-logs','roles' => ['super_admin']],
],
],
];
diff --git a/routes/admin.php b/routes/admin.php
index 169aed3..c430ae5 100644
--- a/routes/admin.php
+++ b/routes/admin.php
@@ -14,7 +14,12 @@ use App\Http\Controllers\Admin\Qna\AdminQnaController;
use App\Http\Controllers\Admin\Members\AdminMembersController;
use App\Http\Controllers\Admin\Members\AdminMemberMarketingController;
use App\Http\Controllers\Admin\Members\AdminMemberJoinFilterController;
-
+use App\Http\Controllers\Admin\Log\AdminAuditLogController;
+use App\Http\Controllers\Admin\Log\MemberJoinLogController;
+use App\Http\Controllers\Admin\Log\MemberLoginLogController;
+use App\Http\Controllers\Admin\Log\MemberPasswdModifyLogController;
+use App\Http\Controllers\Admin\Log\MemberAccountLogController;
+use App\Http\Controllers\Admin\Log\MemberDanalAuthTelLogController;
use Illuminate\Support\Facades\Route;
Route::middleware(['web'])->group(function () {
@@ -241,6 +246,33 @@ Route::middleware(['web'])->group(function () {
->name('destroy');
});
+ Route::prefix('systemlog')
+ ->name('admin.systemlog.')
+ ->middleware('admin.role:super_admin')
+ ->group(function () {
+
+ Route::get('/admin-audit-logs', [AdminAuditLogController::class, 'index'])
+ ->name('admin-audit-logs');
+
+ Route::get('/admin-audit-logs/{id}', [AdminAuditLogController::class, 'show'])
+ ->whereNumber('id')
+ ->name('admin-audit-logs.show');
+
+ Route::get('/member-join-logs', [MemberJoinLogController::class, 'index'])
+ ->name('member-join-logs');
+
+ Route::get('/member-login-logs', [MemberLoginLogController::class, 'index'])
+ ->name('member-login-logs');
+
+ Route::get('/member-passwd-modify-logs', [MemberPasswdModifyLogController::class, 'index'])
+ ->name('member-passwd-modify-logs');
+
+ Route::get('/member-account-logs', [MemberAccountLogController::class, 'index'])
+ ->name('member-account-logs');
+
+ Route::get('/member-danalauthtel-logs', [MemberDanalAuthTelLogController::class, 'index'])
+ ->name('member-danalauthtel-logs');
+ });
});
});