시스템로그 , 다날인증, 계좌번호인증, 비밀번호변경, 로그인로그, 회원가입차단 알림로그, 관리자활동로그 작업

This commit is contained in:
sungro815 2026-02-12 20:15:42 +09:00
parent 6d4195aacd
commit 6e8e8b5a57
31 changed files with 3608 additions and 16 deletions

View File

@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Admin\Log;
use App\Http\Controllers\Controller;
use App\Services\Admin\Log\AdminAuditLogService;
use Illuminate\Http\Request;
final class AdminAuditLogController extends Controller
{
public function __construct(
private readonly AdminAuditLogService $service,
) {}
public function index(Request $request)
{
$data = $this->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,
]);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Admin\Log;
use App\Http\Controllers\Controller;
use App\Services\Admin\Log\MemberAccountLogService;
use Illuminate\Http\Request;
final class MemberAccountLogController extends Controller
{
public function __construct(
private readonly MemberAccountLogService $service,
) {}
public function index(Request $request)
{
$data = $this->service->indexData($request->query());
// index.blade.php 금지 → 컨트롤러명과 동일
return view('admin.log.MemberAccountLogController', $data);
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Admin\Log;
use App\Http\Controllers\Controller;
use App\Services\Admin\Log\MemberDanalAuthTelLogService;
use Illuminate\Http\Request;
final class MemberDanalAuthTelLogController extends Controller
{
public function __construct(
private readonly MemberDanalAuthTelLogService $service,
) {}
public function index(Request $request)
{
$data = $this->service->indexData($request->query());
return view('admin.log.member_danalauthtel_log', $data);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Admin\Log;
use App\Http\Controllers\Controller;
use App\Services\Admin\Log\MemberJoinLogService;
use Illuminate\Http\Request;
final class MemberJoinLogController extends Controller
{
public function __construct(
private readonly MemberJoinLogService $service,
) {}
public function index(Request $request)
{
$data = $this->service->indexData($request->query());
// ✅ index.blade.php 금지 → 컨트롤러명과 동일한 뷰 파일
return view('admin.log.MemberJoinLogController', $data);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Admin\Log;
use App\Http\Controllers\Controller;
use App\Services\Admin\Log\MemberLoginLogService;
use Illuminate\Http\Request;
final class MemberLoginLogController extends Controller
{
public function __construct(
private readonly MemberLoginLogService $service,
) {}
public function index(Request $request)
{
$data = $this->service->indexData($request->query());
// index.blade.php 금지 → 컨트롤러명과 동일
return view('admin.log.MemberLoginLogController', $data);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Admin\Log;
use App\Http\Controllers\Controller;
use App\Services\Admin\Log\MemberPasswdModifyLogService;
use Illuminate\Http\Request;
final class MemberPasswdModifyLogController extends Controller
{
public function __construct(
private readonly MemberPasswdModifyLogService $service,
) {}
public function index(Request $request)
{
$data = $this->service->indexData($request->query());
// index.blade.php 금지 → 컨트롤러명과 동일
return view('admin.log.MemberPasswdModifyLogController', $data);
}
}

View File

@ -413,6 +413,7 @@ final class InfoGateController extends Controller
$repo->updatePasswordOnly($memNo, $newPw); $repo->updatePasswordOnly($memNo, $newPw);
$repo->logPasswordResetSuccess( $repo->logPasswordResetSuccess(
$memNo, $memNo,
$email,
(string) $request->ip(), (string) $request->ip(),
(string) $request->userAgent(), (string) $request->userAgent(),
'S' 'S'
@ -506,6 +507,12 @@ final class InfoGateController extends Controller
try { try {
$repo->updatePin2Only($memNo, $newPin2); $repo->updatePin2Only($memNo, $newPin2);
$repo->logPasswordResetSuccess2Only(
$memNo,
$email,
(string) $request->ip(),
(string) $request->userAgent()
);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('[mypage] pin2 update failed', [ Log::error('[mypage] pin2 update failed', [
'mem_no' => $memNo, 'mem_no' => $memNo,

View File

@ -0,0 +1,99 @@
<?php
namespace App\Repositories\Admin\Log;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
final class AdminAuditLogRepository
{
private const TABLE = 'admin_audit_logs';
public function paginate(array $filters, int $perPage = 30): LengthAwarePaginator
{
$q = 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.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);
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace App\Repositories\Admin\Log;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
final class MemberAccountLogRepository
{
private const TABLE = 'mem_account_log';
public function paginate(array $filters, int $perPage = 30): LengthAwarePaginator
{
$q = DB::table(self::TABLE . ' as l')
->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);
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Repositories\Admin\Log;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
final class MemberDanalAuthTelLogRepository
{
private const TABLE = 'mem_danalauthtel_log';
public function paginate(array $filters, int $perPage = 30): LengthAwarePaginator
{
$q = DB::table(self::TABLE)->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();
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace App\Repositories\Admin\Log;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
final class MemberJoinLogRepository
{
private const TABLE = 'mem_join_log';
public function paginate(array $filters, int $perPage = 30): LengthAwarePaginator
{
$q = DB::table(self::TABLE . ' as l')
->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);
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Repositories\Admin\Log;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
final class MemberLoginLogRepository
{
public function paginateByYear(int $year, array $filters, int $perPage = 30): LengthAwarePaginator
{
$table = 'mem_login_' . $year;
$q = DB::table($table . ' as l')
->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);
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace App\Repositories\Admin\Log;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
final class MemberPasswdModifyLogRepository
{
private const TABLE = 'mem_passwd_modify';
public function paginate(array $filters, int $perPage = 30): LengthAwarePaginator
{
$q = DB::table(self::TABLE . ' as p')
->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);
}
}

View File

@ -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'); $now = now()->format('Y-m-d H:i:s');
@ -756,6 +756,27 @@ class MemberAuthRepository
'state' => $state, 'state' => $state,
'info' => json_encode([ 'info' => json_encode([
'mem_no' => (string)$memNo, '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, 'redate' => $now,
'remote_addr' => $ip, 'remote_addr' => $ip,
'agent' => substr((string)$agent, 0, 500), // 길이 방어(일관 처리) 'agent' => substr((string)$agent, 0, 500), // 길이 방어(일관 처리)

View File

@ -0,0 +1,106 @@
<?php
namespace App\Services\Admin\Log;
use App\Repositories\Admin\Log\AdminAuditLogRepository;
final class AdminAuditLogService
{
public function __construct(
private readonly AdminAuditLogRepository $repo,
) {}
public function indexData(array $query = []): array
{
$filters = [
'date_from' => $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;
}
}

View File

@ -0,0 +1,160 @@
<?php
namespace App\Services\Admin\Log;
use App\Repositories\Admin\Log\MemberAccountLogRepository;
final class MemberAccountLogService
{
public function __construct(
private readonly MemberAccountLogRepository $repo,
) {}
public function indexData(array $query = []): array
{
$filters = [
'date_from' => $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;
}
}

View File

@ -0,0 +1,92 @@
<?php
namespace App\Services\Admin\Log;
use App\Repositories\Admin\Log\MemberDanalAuthTelLogRepository;
final class MemberDanalAuthTelLogService
{
public function __construct(
private readonly MemberDanalAuthTelLogRepository $repo,
) {}
public function indexData(array $query = []): array
{
$filters = [
'mem_no' => 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);
}
}

View File

@ -0,0 +1,179 @@
<?php
namespace App\Services\Admin\Log;
use App\Repositories\Admin\Log\MemberJoinLogRepository;
use App\Support\LegacyCrypto\CiSeedCrypto;
final class MemberJoinLogService
{
public function __construct(
private readonly MemberJoinLogRepository $repo,
) {}
public function indexData(array $query = []): array
{
$filters = [
'date_from' => $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+(알뜰)',
];
}
}

View File

@ -0,0 +1,129 @@
<?php
namespace App\Services\Admin\Log;
use App\Repositories\Admin\Log\MemberLoginLogRepository;
use Illuminate\Support\Facades\Schema;
final class MemberLoginLogService
{
public function __construct(
private readonly MemberLoginLogRepository $repo,
) {}
public function indexData(array $query = []): array
{
$currentYear = (int)date('Y');
$years = $this->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 : '';
}
}

View File

@ -0,0 +1,162 @@
<?php
namespace App\Services\Admin\Log;
use App\Repositories\Admin\Log\MemberPasswdModifyLogRepository;
final class MemberPasswdModifyLogService
{
public function __construct(
private readonly MemberPasswdModifyLogRepository $repo,
) {}
public function indexData(array $query = []): array
{
$filters = [
'date_from' => $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 : '';
}
}

View File

@ -228,6 +228,7 @@ class FindPasswordService
$this->members->updatePasswordOnly($memNo, $newPassword); $this->members->updatePasswordOnly($memNo, $newPassword);
$this->members->logPasswordResetSuccess( $this->members->logPasswordResetSuccess(
$memNo, $memNo,
$member->email,
(string) $request->ip(), (string) $request->ip(),
(string) $request->userAgent(), (string) $request->userAgent(),
'E' 'E'

View File

@ -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);
```

View File

@ -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')
<style>
.bar{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;}
.filters .inp{width:170px;}
.filters .inpWide{width:220px;}
.lbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
.lbtn--ghost{background:transparent;}
.lbtn--sm{padding:7px 10px;font-size:12px;border-radius:11px;}
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;display:inline-block;}
.muted{opacity:.8;font-size:12px;}
.nowrap{white-space:nowrap;}
.lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
.lbtn--primary:hover{background:rgba(59,130,246,.98);}
/* modal */
.mback{position:fixed;inset:0;display:none;align-items:center;justify-content:center;z-index:9999;
background:rgba(0,0,0,.55);backdrop-filter:blur(2px);}
.mback.is-open{display:flex;}
.modalx{width:min(1100px, 94vw);max-height:88vh;overflow:auto;border-radius:18px;
border:1px solid rgba(255,255,255,.12);background:rgba(18,18,18,.96);box-shadow:0 20px 60px rgba(0,0,0,.45);}
.modalx__head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;padding:16px 16px 10px;border-bottom:1px solid rgba(255,255,255,.10);}
.modalx__title{font-weight:900;font-size:16px;}
.modalx__desc{font-size:12px;opacity:.8;margin-top:4px;}
.modalx__body{padding:16px;}
.modalx__foot{display:flex;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid rgba(255,255,255,.10);}
.infoGrid{display:grid;grid-template-columns:1fr;gap:10px;margin-bottom:12px;}
@media (min-width: 920px){ .infoGrid{grid-template-columns:1fr 1fr;} }
.ibox{border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.03);border-radius:14px;padding:10px;}
.ibox .k{font-size:12px;opacity:.75;margin-bottom:4px;}
.ibox .v{font-size:13px;word-break:break-word;}
.jsonGrid{display:grid;grid-template-columns:1fr;gap:12px;}
@media (min-width: 1000px){ .jsonGrid{grid-template-columns:1fr 1fr;} }
.prebox{border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.03);border-radius:14px;padding:10px;overflow:auto;}
.prebox .k{font-size:12px;opacity:.75;margin-bottom:8px;font-weight:800;}
pre{margin:0;white-space:pre; font-size:12px; line-height:1.5;
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;}
</style>
@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
<div class="a-card" style="padding:16px; margin-bottom:16px;">
<div class="bar">
<div>
<div style="font-weight:900;font-size:16px;">관리자 감사 로그</div>
<div class="muted" style="margin-top:4px;">검색/페이징 지원 · 상세는 AJAX 모달</div>
</div>
<form method="GET" action="{{ $indexUrl }}" class="filters">
<div>
<label class="muted">From</label>
<input class="a-input inp" type="date" name="date_from" value="{{ $dateFrom }}">
</div>
<div>
<label class="muted">To</label>
<input class="a-input inp" type="date" name="date_to" value="{{ $dateTo }}">
</div>
<div>
<label class="muted">Actor(이메일/이름)</label>
<input class="a-input inpWide" name="actor_q" value="{{ $actorQ }}" placeholder="ex) sungro / gmail">
</div>
<div>
<label class="muted">action</label>
<input class="a-input inp" name="action" value="{{ $action }}" placeholder="ex) admin_user_update">
</div>
<div>
<label class="muted">target_type</label>
<input class="a-input inp" name="target_type" value="{{ $tt }}" placeholder="ex) admin_users">
</div>
<div>
<label class="muted">ip</label>
<input class="a-input inp" name="ip" value="{{ $ip }}" placeholder="ex) 210.96.">
</div>
<div style="display:flex;gap:8px;align-items:flex-end;">
<button class="lbtn lbtn--ghost" type="submit">검색</button>
<a class="lbtn lbtn--ghost" href="{{ $indexUrl }}">초기화</a>
</div>
</form>
</div>
</div>
<div class="a-card" style="padding:16px;">
<div class="a-muted" style="margin-bottom:10px;"> <b>{{ $page->total() }}</b></div>
<div style="overflow:auto;">
<table class="a-table table" style="width:100%; min-width:1050px;">
<thead>
<tr>
<th style="width:90px;">ID</th>
<th style="width:260px;">Actor</th>
<th style="width:220px;">Action</th>
<th style="width:200px;">Target Type</th>
<th style="width:180px;">IP</th>
<th style="width:190px;">Created</th>
<th style="width:120px;">상세</th>
</tr>
</thead>
<tbody>
@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
<tr>
<td class="a-muted">{{ $id }}</td>
<td>
<div style="font-weight:900;">{{ $aName !== '' ? $aName : '-' }}</div>
<div class="a-muted" style="font-size:12px;">{{ $aEmail !== '' ? $aEmail : 'admin_users 미조회' }}</div>
</td>
<td><span class="mono">{{ $r['action'] ?? '-' }}</span></td>
<td><span class="mono">{{ $r['target_type'] ?? '-' }}</span></td>
<td class="nowrap">{{ $r['ip'] ?? '-' }}</td>
<td class="a-muted">{{ $r['created_at'] ?? '-' }}</td>
<td style="text-align:right;">
<button type="button"
class="lbtn lbtn--sm btnAuditDetail"
data-id="{{ $id }}">
상세보기
</button>
</td>
</tr>
@empty
<tr><td colspan="7" class="a-muted" style="padding:16px;">데이터가 없습니다.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div style="margin-top:12px;">
{{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
</div>
</div>
@include('admin.log._audit_log_modal')
<script>
(function(){
const showTpl = @json($showTpl);
const modalBack = document.getElementById('auditModalBack');
const btnClose = document.getElementById('auditBtnClose');
const btnOk = document.getElementById('auditBtnOk');
const el = (id) => document.getElementById(id);
const setText = (node, text) => {
if (!node) return;
node.textContent = (text === null || text === undefined || text === '') ? '-' : String(text);
};
const openModal = () => {
modalBack.classList.add('is-open');
modalBack.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
};
const closeModal = () => {
modalBack.classList.remove('is-open');
modalBack.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
};
btnClose?.addEventListener('click', closeModal);
btnOk?.addEventListener('click', closeModal);
modalBack?.addEventListener('click', (e) => { if (e.target === modalBack) closeModal(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modalBack.classList.contains('is-open')) closeModal(); });
const fillLoading = () => {
setText(el('auditTitle'), '감사로그 상세');
setText(el('auditDesc'), '로딩 중...');
setText(el('audit_id'), '-');
setText(el('audit_actor'), '-');
setText(el('audit_action'), '-');
setText(el('audit_target'), '-');
setText(el('audit_ip'), '-');
setText(el('audit_created'), '-');
setText(el('audit_ua'), '-');
setText(el('audit_before'), '로딩 중...');
setText(el('audit_after'), '로딩 중...');
};
const fillItem = (item) => {
const actor = `${item.actor_name || '-'} (${item.actor_email || '-'}) [#${item.actor_admin_user_id || 0}]`;
setText(el('auditTitle'), `감사로그 #${item.id}`);
setText(el('auditDesc'), 'before/after JSON과 user_agent를 확인하세요.');
setText(el('audit_id'), item.id);
setText(el('audit_actor'), actor);
setText(el('audit_action'), item.action);
setText(el('audit_target'), `${item.target_type} #${item.target_id}`);
setText(el('audit_ip'), item.ip);
setText(el('audit_created'), item.created_at);
setText(el('audit_ua'), item.user_agent);
setText(el('audit_before'), item.before_pretty);
setText(el('audit_after'), item.after_pretty);
};
async function loadDetail(id){
const url = showTpl.replace('__ID__', encodeURIComponent(id));
const res = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
});
if (res.status === 401 || res.status === 419) throw new Error('AUTH');
if (res.status === 403) throw new Error('FORBIDDEN');
if (!res.ok) throw new Error('HTTP_' + res.status);
const ct = (res.headers.get('content-type') || '').toLowerCase();
if (!ct.includes('application/json')) throw new Error('NOT_JSON');
const payload = await res.json();
if (!payload?.ok || !payload?.item) throw new Error('NOT_FOUND');
return payload.item;
}
document.querySelectorAll('.btnAuditDetail').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-id');
if (!id) return;
try {
btn.disabled = true;
fillLoading();
openModal();
const item = await loadDetail(id);
fillItem(item);
} catch (e) {
if (String(e.message) === 'AUTH') alert('세션이 만료되었습니다. 다시 로그인해 주세요.');
else if (String(e.message) === 'FORBIDDEN') alert('권한이 없습니다. (super_admin만 접근)');
else if (String(e.message) === 'NOT_JSON') alert('응답이 JSON이 아닙니다. (프록시/APP_URL/리다이렉트 가능)');
else alert('상세 정보를 불러오지 못했습니다.');
closeModal();
} finally {
btn.disabled = false;
}
});
});
})();
</script>
@endsection

View File

@ -0,0 +1,380 @@
@extends('admin.layouts.app')
@section('title', '계좌 성명인증 로그')
@section('page_title', '계좌 성명인증 로그')
@section('page_desc', '더즌 성명인증 요청/결과 로그를 조회합니다.')
@section('content_class', 'a-content--full')
@push('head')
<style>
.bar{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;}
.filters .inp{width:160px;}
.filters .inpWide{width:220px;}
.filters .sel{width:140px;}
.lbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
.lbtn--ghost{background:transparent;}
.lbtn--sm{padding:7px 10px;font-size:12px;border-radius:11px;}
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;display:inline-block;}
.muted{opacity:.8;font-size:12px;}
.nowrap{white-space:nowrap;}
.ellipsis{max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:bottom;}
.badge{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:12px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);}
.badge--ok{border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.10);}
.badge--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
a.memlink{color:#fff;text-decoration:none;}
a.memlink:hover{color:#fff;text-decoration:underline;}
/* modal */
.mback{position:fixed;inset:0;display:none;align-items:center;justify-content:center;z-index:9999;
background:rgba(0,0,0,.55);backdrop-filter:blur(2px);}
.mback.is-open{display:flex;}
.modalx{width:min(980px, 92vw);max-height:88vh;overflow:hidden;border-radius:18px;
border:1px solid rgba(255,255,255,.12);background:rgba(18,18,18,.96);box-shadow:0 20px 60px rgba(0,0,0,.45);}
.modalx__head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;padding:16px 16px 10px;border-bottom:1px solid rgba(255,255,255,.10);}
.modalx__title{font-weight:900;font-size:16px;}
.modalx__desc{font-size:12px;opacity:.8;margin-top:4px;}
.modalx__body{padding:16px;overflow:auto;max-height:70vh;}
.modalx__foot{display:flex;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid rgba(255,255,255,.10);}
.kv{display:grid;grid-template-columns:170px 1fr;gap:8px;margin-bottom:12px;}
.kv .k{opacity:.75;font-size:12px;}
.kv .v{font-size:12px;word-break:break-all;}
.prebox{border:1px solid rgba(255,255,255,.10);border-radius:14px;background:rgba(255,255,255,.03);padding:12px;margin-top:12px;}
.prebox .t{font-weight:900;font-size:12px;opacity:.85;margin-bottom:8px;}
.prebox pre{margin:0;white-space:pre-wrap;word-break:break-word;font-size:12px;line-height:1.5;
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;}
</style>
@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
<div class="a-card" style="padding:16px; margin-bottom:16px;">
<div class="bar">
<div>
<div style="font-weight:900;font-size:16px;">계좌 성명인증 로그</div>
<div class="muted" style="margin-top:4px;">요청/결과 JSON은 모달로 표시 (row 높이 고정)</div>
</div>
<form method="GET" action="{{ $indexUrl }}" class="filters">
<div>
<label class="muted">From</label>
<input class="a-input inp" type="date" name="date_from" value="{{ $dateFrom }}">
</div>
<div>
<label class="muted">To</label>
<input class="a-input inp" type="date" name="date_to" value="{{ $dateTo }}">
</div>
<div>
<label class="muted">mem_no</label>
<input class="a-input inp" name="mem_no" value="{{ $memNo }}" inputmode="numeric" placeholder="41970">
</div>
<div>
<label class="muted">bank_code</label>
<input class="a-input inp" name="bank_code" value="{{ $bank }}" placeholder="003">
</div>
<div>
<label class="muted">status</label>
<select class="a-input sel" name="status">
<option value="">전체</option>
<option value="ok" {{ $status==='ok'?'selected':'' }}>성공(200)</option>
<option value="fail" {{ $status==='fail'?'selected':'' }}>실패(!=200)</option>
<option value="200" {{ $status==='200'?'selected':'' }}>200</option>
<option value="520" {{ $status==='520'?'selected':'' }}>520</option>
</select>
</div>
<div>
<label class="muted">account</label>
<input class="a-input inpWide" name="account" value="{{ $account }}" placeholder="계좌 일부">
</div>
<div>
<label class="muted">name</label>
<input class="a-input inp" name="name" value="{{ $name }}" placeholder="예금주">
</div>
<div>
<label class="muted">q(JSON)</label>
<input class="a-input inpWide" name="q" value="{{ $q }}" placeholder="error_code / natv_tr_no ...">
</div>
<div style="display:flex;gap:8px;align-items:flex-end;">
<button class="lbtn lbtn--ghost" type="submit">검색</button>
<a class="lbtn lbtn--ghost" href="{{ $indexUrl }}">초기화</a>
</div>
</form>
</div>
</div>
<div class="a-card" style="padding:16px;">
<div class="a-muted" style="margin-bottom:10px;"> <b>{{ $page->total() }}</b></div>
<div style="overflow:auto;">
<table class="a-table table" style="width:100%; min-width:1400px;">
<thead>
<tr>
<th style="width:90px;">SEQ</th>
<th style="width:190px;">request_time</th>
<th style="width:190px;">result_time</th>
<th style="width:140px;">mem_no</th>
<th style="width:120px;">bank</th>
<th style="width:260px;">account</th>
<th style="width:180px;">name</th>
<th style="width:120px;">protype</th>
<th style="width:120px;">status</th>
<th style="width:220px;">depositor/error</th>
<th style="width:140px;">JSON</th>
</tr>
</thead>
<tbody>
@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
<tr>
<td class="a-muted">{{ $seq }}</td>
<td class="a-muted nowrap">{{ $r['request_time'] ?? '-' }}</td>
<td class="a-muted nowrap">{{ $r['result_time'] ?? '-' }}</td>
<td>
@if($memNoInt > 0 && $memLink)
<a href="{{ $memLink }}" class="mono memlink" target="_blank" rel="noopener">{{ $memNoInt }}</a>
@else
<span class="mono">-</span>
@endif
</td>
<td>
@if($bankCode !== '')
<span class="mono">{{ $bankCode }}</span>
@else
<span class="a-muted">-</span>
@endif
</td>
<td>
@if($acctMask !== '')
<span class="mono">{{ $acctMask }}</span>
@else
<span class="a-muted">-</span>
@endif
</td>
<td>
@if($nameV !== '')
<span class="mono">{{ $nameV }}</span>
@else
<span class="a-muted">-</span>
@endif
</td>
<td>
@if($proType !== '')
<span class="mono">{{ $proType }}</span>
@else
<span class="a-muted">-</span>
@endif
</td>
<td>
<span class="badge {{ $badge }}">
{{ $label }} ({{ $statusInt }})
</span>
</td>
<td>
@if($depositor !== '')
<span class="mono ellipsis" title="{{ $depositor }}">{{ $depositor }}</span>
@elseif($errCode !== '' || $errMsg !== '')
<span class="mono ellipsis" title="{{ trim($errCode.' '.$errMsg) }}">{{ trim($errCode.' '.$errMsg) }}</span>
@else
<span class="a-muted">-</span>
@endif
</td>
<td class="nowrap" style="text-align:right;">
<button type="button"
class="lbtn lbtn--ghost lbtn--sm btnJson"
data-seq="{{ $seq }}"
data-mem="{{ $memNoInt }}"
data-bank="{{ e($bankCode) }}"
data-account="{{ e((string)($r['account'] ?? '')) }}"
data-name="{{ e($nameV) }}"
data-protype="{{ e($proType) }}"
data-status="{{ $statusInt }}"
data-err="{{ e(trim($errCode.' '.$errMsg)) }}"
data-depositor="{{ e($depositor) }}">
JSON 보기
</button>
<textarea id="reqStore-{{ $seq }}" style="display:none;">{{ $r['request_pretty'] ?? ($r['request_data'] ?? '') }}</textarea>
<textarea id="resStore-{{ $seq }}" style="display:none;">{{ $r['result_pretty'] ?? ($r['result_data'] ?? '') }}</textarea>
</td>
</tr>
@empty
<tr><td colspan="11" class="a-muted" style="padding:16px;">데이터가 없습니다.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div style="margin-top:12px;">
{{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
</div>
</div>
{{-- JSON Modal --}}
<div class="mback" id="jsonModalBack" aria-hidden="true">
<div class="modalx" role="dialog" aria-modal="true" aria-labelledby="jsonModalTitle">
<div class="modalx__head">
<div>
<div class="modalx__title" id="jsonModalTitle">계좌 성명인증 JSON</div>
<div class="modalx__desc" id="jsonModalDesc">요청/결과 JSON</div>
</div>
<button class="lbtn lbtn--ghost lbtn--sm" type="button" id="btnJsonClose">닫기 </button>
</div>
<div class="modalx__body">
<div class="kv" id="jsonModalKv"></div>
<div class="prebox">
<div class="t">Request JSON</div>
<pre id="jsonReqPre"></pre>
</div>
<div class="prebox">
<div class="t">Result JSON</div>
<pre id="jsonResPre"></pre>
</div>
</div>
<div class="modalx__foot">
<button class="lbtn lbtn--ghost" type="button" id="btnJsonCopy">복사</button>
<button class="lbtn lbtn--primary" type="button" id="btnJsonOk">확인</button>
</div>
</div>
</div>
<script>
(function(){
const back = document.getElementById('jsonModalBack');
const kv = document.getElementById('jsonModalKv');
const preReq = document.getElementById('jsonReqPre');
const preRes = document.getElementById('jsonResPre');
const btnClose = document.getElementById('btnJsonClose');
const btnOk = document.getElementById('btnJsonOk');
const btnCopy = document.getElementById('btnJsonCopy');
const open = () => {
back.classList.add('is-open');
back.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
};
const close = () => {
back.classList.remove('is-open');
back.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
};
btnClose?.addEventListener('click', close);
btnOk?.addEventListener('click', close);
back?.addEventListener('click', (e)=>{ if(e.target === back) close(); });
document.addEventListener('keydown', (e)=>{ if(e.key === 'Escape' && back.classList.contains('is-open')) close(); });
btnCopy?.addEventListener('click', async () => {
const text = (preReq.textContent || '') + "\n\n" + (preRes.textContent || '');
try {
await navigator.clipboard.writeText(text);
btnCopy.textContent = '복사됨';
setTimeout(()=>btnCopy.textContent='복사', 900);
} catch (e) {
alert('복사를 지원하지 않는 환경입니다.');
}
});
const renderKv = (pairs) => {
kv.innerHTML = '';
pairs.filter(p => p.v && String(p.v).trim() !== '').forEach(p => {
const k = document.createElement('div'); k.className='k'; k.textContent=p.k;
const v = document.createElement('div'); v.className='v';
const span = document.createElement('span'); span.className='mono'; span.textContent=p.v;
v.appendChild(span);
kv.appendChild(k); kv.appendChild(v);
});
};
document.querySelectorAll('.btnJson').forEach(btn => {
btn.addEventListener('click', () => {
const seq = btn.getAttribute('data-seq');
if (!seq) return;
const reqTa = document.getElementById('reqStore-' + seq);
const resTa = document.getElementById('resStore-' + seq);
const req = reqTa ? reqTa.value : '';
const res = resTa ? resTa.value : '';
renderKv([
{k:'mem_no', v: btn.getAttribute('data-mem') || ''},
{k:'bank_code', v: btn.getAttribute('data-bank') || ''},
{k:'account', v: btn.getAttribute('data-account') || ''},
{k:'name', v: btn.getAttribute('data-name') || ''},
{k:'protype', v: btn.getAttribute('data-protype') || ''},
{k:'status', v: btn.getAttribute('data-status') || ''},
{k:'depositor', v: btn.getAttribute('data-depositor') || ''},
{k:'error', v: btn.getAttribute('data-err') || ''},
]);
preReq.textContent = req || '';
preRes.textContent = res || '';
open();
});
});
})();
</script>
@endsection

View File

@ -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')
<style>
.bar{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;}
.filters .inp{width:170px;}
.filters .inpWide{width:220px;}
.lbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
.lbtn--ghost{background:transparent;}
.lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
.lbtn--primary:hover{background:rgba(59,130,246,.98);}
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;display:inline-block;}
.muted{opacity:.8;font-size:12px;}
.nowrap{white-space:nowrap;}
.badge{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:12px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);}
.badge--skt{border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.10);}
.badge--kt{border-color:rgba(59,130,246,.35);background:rgba(59,130,246,.10);}
.badge--lgu{border-color:rgba(236,72,153,.35);background:rgba(236,72,153,.10);}
.badge--mvno{border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.10);}
.badge--muted{opacity:.9;}
.badge--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
/* ✅ 회원번호 링크 색상 흰색 */
a.memlink{color:#fff;text-decoration:none;}
a.memlink:hover{color:#fff;text-decoration:underline;}
</style>
@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
<div class="a-card" style="padding:16px; margin-bottom:16px;">
<div class="bar">
<div>
<div style="font-weight:900;font-size:16px;">회원가입 필터 로그</div>
<div class="muted" style="margin-top:4px;">리스트에서 전체 정보 표시 · 검색/페이징 지원</div>
</div>
<form method="GET" action="{{ $indexUrl }}" class="filters">
<div>
<label class="muted">From</label>
<input class="a-input inp" type="date" name="date_from" value="{{ $dateFrom }}">
</div>
<div>
<label class="muted">To</label>
<input class="a-input inp" type="date" name="date_to" value="{{ $dateTo }}">
</div>
<div>
<label class="muted">gubun</label>
<input class="a-input inp" name="gubun" value="{{ $gubun }}" placeholder="ex) 01">
</div>
<div>
<label class="muted">mem_no</label>
<input class="a-input inp" name="mem_no" value="{{ $memNo }}" inputmode="numeric" placeholder="ex) 70464">
</div>
<div>
<label class="muted">phone(정확검색)</label>
<input class="a-input inp" name="phone" value="{{ $phone }}" placeholder="01111111111">
</div>
<div>
<label class="muted">email</label>
<input class="a-input inpWide" name="email" value="{{ $email }}" placeholder="gmail.com / ryu">
</div>
<div>
<label class="muted">ip4</label>
<input class="a-input inp" name="ip4" value="{{ $ip4 }}" placeholder="221.150.">
</div>
<div>
<label class="muted">ip4_c</label>
<input class="a-input inp" name="ip4_c" value="{{ $ip4c }}" placeholder="221.150.109">
</div>
<div style="display:flex;gap:8px;align-items:flex-end;">
<button class="lbtn lbtn--ghost" type="submit">검색</button>
<a class="lbtn lbtn--ghost" href="{{ $indexUrl }}">초기화</a>
</div>
</form>
</div>
</div>
<div class="a-card" style="padding:16px;">
<div class="a-muted" style="margin-bottom:10px;"> <b>{{ $page->total() }}</b></div>
<div style="overflow:auto;">
<table class="a-table table" style="width:100%; min-width:1100px;">
<thead>
<tr>
<th style="width:90px;">SEQ</th>
<th style="width:190px;">일시</th>
<th style="width:160px;">회원</th>
<th style="width:420px;">전화/이메일</th>
<th style="width:260px;">IP</th>
<th style="width:120px;">gubun</th>
</tr>
</thead>
<tbody>
@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
<tr>
<td class="a-muted">{{ $seq }}</td>
<td class="a-muted nowrap">{{ $r['dt_reg'] ?? '-' }}</td>
<td>
@if($memNoInt > 0 && $memLink)
<a href="{{ $memLink }}" class="mono memlink" target="_blank" rel="noopener">{{ $memNoInt }}</a>
@else
<span class="badge badge--bad">가입차단</span>
@endif
</td>
{{-- 전화/이메일 한줄: "전화번호 바로뒤 이메일" (이메일 없으면 빈값) --}}
<td>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<span class="badge {{ $r['corp_badge'] ?? 'badge--muted' }}">{{ $r['corp_label'] ?? '-' }}</span>
<span class="mono">{{ $r['phone_display'] ?? '-' }}</span>
@if($emailDisp !== '')
<span class="muted">{{ $emailDisp }}</span>
@endif
</div>
</td>
{{-- IP 한줄: ip4 + ip4_c --}}
<td class="nowrap">
<span class="mono">{{ $ip4v !== '' ? $ip4v : '-' }}</span>
@if($ip4cv !== '')
<span class="mono" style="opacity:.75">{{ $ip4cv }}</span>
@endif
</td>
<td><span class="mono">{{ $r['gubun'] ?? '-' }}</span></td>
</tr>
@empty
<tr><td colspan="6" class="a-muted" style="padding:16px;">데이터가 없습니다.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div style="margin-top:12px;">
{{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
</div>
</div>
@endsection

View File

@ -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')
<style>
.bar{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;}
.filters .inp{width:160px;}
.filters .inpWide{width:200px;}
.filters .sel{width:140px;}
.lbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
.lbtn--ghost{background:transparent;}
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;display:inline-block;}
.muted{opacity:.8;font-size:12px;}
.nowrap{white-space:nowrap;}
.badge{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:12px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);}
.badge--ok{border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.10);}
.badge--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
.badge--muted{opacity:.9;}
.badge--mvno{border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.10);}
/* mem link white */
a.memlink{color:#fff;text-decoration:none;}
a.memlink:hover{color:#fff;text-decoration:underline;}
</style>
@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
<div class="a-card" style="padding:16px; margin-bottom:16px;">
<div class="bar">
<div>
<div style="font-weight:900;font-size:16px;">로그인 로그</div>
<div class="muted" style="margin-top:4px;">연도별(mem_login_YYYY) 테이블 조회 · 검색/페이징</div>
</div>
<form method="GET" action="{{ $indexUrl }}" class="filters" id="frmFilters">
<div>
<label class="muted">년도</label>
<select class="a-input sel" name="year" id="selYear">
@foreach($years as $y)
<option value="{{ $y }}" {{ (int)$y === (int)$year ? 'selected' : '' }}>{{ $y }}</option>
@endforeach
</select>
</div>
<div>
<label class="muted">From</label>
<input class="a-input inp" type="date" name="date_from" value="{{ $dateFrom }}">
</div>
<div>
<label class="muted">To</label>
<input class="a-input inp" type="date" name="date_to" value="{{ $dateTo }}">
</div>
<div>
<label class="muted">mem_no</label>
<input class="a-input inp" name="mem_no" value="{{ $memNo }}" inputmode="numeric" placeholder="ex) 70665">
</div>
<div>
<label class="muted">성공/실패</label>
<select class="a-input sel" name="sf">
<option value="">전체</option>
<option value="s" {{ $sf==='s'?'selected':'' }}>성공</option>
<option value="f" {{ $sf==='f'?'selected':'' }}>실패</option>
</select>
</div>
<div>
<label class="muted">PC/M</label>
<select class="a-input sel" name="conn">
<option value="">전체</option>
<option value="1" {{ $conn==='1'?'selected':'' }}>PC</option>
<option value="2" {{ $conn==='2'?'selected':'' }}>Mobile</option>
</select>
</div>
<div>
<label class="muted">ip4</label>
<input class="a-input inp" name="ip4" value="{{ $ip4 }}" placeholder="211.235.">
</div>
<div>
<label class="muted">ip4_c</label>
<input class="a-input inp" name="ip4_c" value="{{ $ip4c }}" placeholder="211.235.75">
</div>
<div>
<label class="muted">error_code</label>
<input class="a-input inp" name="error_code" value="{{ $err }}" placeholder="pw / E3">
</div>
<div>
<label class="muted">platform</label>
<input class="a-input inp" name="platform" value="{{ $platform }}" placeholder="Android / iOS">
</div>
<div>
<label class="muted">browser</label>
<input class="a-input inpWide" name="browser" value="{{ $browser }}" placeholder="Chrome / Safari">
</div>
<div style="display:flex;gap:8px;align-items:flex-end;">
<button class="lbtn lbtn--ghost" type="submit">검색</button>
<a class="lbtn lbtn--ghost" href="{{ $indexUrl }}">초기화</a>
</div>
</form>
</div>
</div>
<div class="a-card" style="padding:16px;">
<div class="a-muted" style="margin-bottom:10px;"> <b>{{ $page->total() }}</b></div>
<div style="overflow:auto;">
<table class="a-table table" style="width:100%; min-width:1200px;">
<thead>
<tr>
<th style="width:90px;">SEQ</th>
<th style="width:210px;">일시</th>
<th style="width:150px;">회원</th>
<th style="width:140px;">결과</th>
<th style="width:140px;">경로</th>
<th style="width:320px;">IP</th>
<th>플랫폼/브라우저</th>
<th style="width:160px;">실패코드</th>
</tr>
</thead>
<tbody>
@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
<tr>
<td class="a-muted">{{ $seq }}</td>
<td class="a-muted nowrap">{{ $r['dt_reg'] ?? '-' }}</td>
<td>
@if($memNoInt > 0 && $memLink)
<a href="{{ $memLink }}" class="mono memlink" target="_blank" rel="noopener">{{ $memNoInt }}</a>
@else
<span class="mono">0</span>
@endif
</td>
<td>
<span class="badge {{ $r['sf_badge'] ?? 'badge--muted' }}">{{ $r['sf_label'] ?? '-' }}</span>
</td>
<td>
<span class="badge {{ $r['conn_badge'] ?? 'badge--muted' }}">{{ $r['conn_label'] ?? '-' }}</span>
</td>
<td class="nowrap">
<span class="mono">{{ $ip4 !== '' ? $ip4 : '-' }}</span>
@if($ip4c !== '')
<span class="mono" style="opacity:.75">{{ $ip4c }}</span>
@endif
</td>
<td>
<span class="mono">{{ $plat !== '' ? $plat : '-' }}</span>
<span class="mono" style="opacity:.85">{{ $brow !== '' ? $brow : '-' }}</span>
</td>
<td>
@if($errShow !== '')
<span class="mono">{{ $errShow }}</span>
@else
<span class="a-muted">-</span>
@endif
</td>
</tr>
@empty
<tr><td colspan="8" class="a-muted" style="padding:16px;">데이터가 없습니다.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div style="margin-top:12px;">
{{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
</div>
</div>
<script>
(function(){
const sel = document.getElementById('selYear');
const frm = document.getElementById('frmFilters');
if (!sel || !frm) return;
sel.addEventListener('change', () => {
// ✅ 선택 즉시 이동(= GET submit), page 파라미터는 자동 리셋
frm.submit();
});
})();
</script>
@endsection

View File

@ -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')
<style>
.bar{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;}
.filters .inp{width:160px;}
.filters .inpWide{width:220px;}
.filters .sel{width:140px;}
.lbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
.lbtn--ghost{background:transparent;}
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;display:inline-block;}
.muted{opacity:.8;font-size:12px;}
.nowrap{white-space:nowrap;}
.ellipsis{max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:bottom;}
.badge{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:12px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);}
.badge--ok{border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.10);}
.badge--warn{border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.10);}
/* mem link white */
a.memlink{color:#fff;text-decoration:none;}
a.memlink:hover{color:#fff;text-decoration:underline;}
details{border:1px solid rgba(255,255,255,.10);border-radius:12px;background:rgba(255,255,255,.03);padding:10px;}
details summary{cursor:pointer;user-select:none;font-weight:800;font-size:12px;opacity:.9;}
pre{margin:10px 0 0;white-space:pre-wrap;word-break:break-word;font-size:12px;line-height:1.5;
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;}
.kv{display:grid;grid-template-columns:160px 1fr;gap:8px;margin-top:10px;}
.kv .k{opacity:.75;font-size:12px;}
.kv .v{font-size:12px;word-break:break-word;}
/* json modal */
.mback{position:fixed;inset:0;display:none;align-items:center;justify-content:center;z-index:9999;
background:rgba(0,0,0,.55);backdrop-filter:blur(2px);}
.mback.is-open{display:flex;}
.modalx{width:min(980px, 92vw);max-height:88vh;overflow:hidden;border-radius:18px;
border:1px solid rgba(255,255,255,.12);background:rgba(18,18,18,.96);box-shadow:0 20px 60px rgba(0,0,0,.45);}
.modalx__head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;padding:16px 16px 10px;border-bottom:1px solid rgba(255,255,255,.10);}
.modalx__title{font-weight:900;font-size:16px;}
.modalx__desc{font-size:12px;opacity:.8;margin-top:4px;}
.modalx__body{padding:16px;overflow:auto;max-height:70vh;}
.modalx__foot{display:flex;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid rgba(255,255,255,.10);}
.kv{display:grid;grid-template-columns:170px 1fr;gap:8px;margin-bottom:12px;}
.kv .k{opacity:.75;font-size:12px;}
.kv .v{font-size:12px;word-break:break-all;} /* auth_key 같은 긴 문자열 깨짐 방지 */
.prebox{border:1px solid rgba(255,255,255,.10);border-radius:14px;background:rgba(255,255,255,.03);padding:12px;}
.prebox pre{margin:0;white-space:pre-wrap;word-break:break-word;font-size:12px;line-height:1.5;
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;}
</style>
@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
<div class="a-card" style="padding:16px; margin-bottom:16px;">
<div class="bar">
<div>
<div style="font-weight:900;font-size:16px;">비밀번호 변경 로그</div>
<div class="muted" style="margin-top:4px;">/ JSON 혼재 핵심 필드 정규화 + 원본 JSON 펼쳐보기</div>
</div>
<form method="GET" action="{{ $indexUrl }}" class="filters">
<div>
<label class="muted">From</label>
<input class="a-input inp" type="date" name="date_from" value="{{ $dateFrom }}">
</div>
<div>
<label class="muted">To</label>
<input class="a-input inp" type="date" name="date_to" value="{{ $dateTo }}">
</div>
<div>
<label class="muted">state</label>
<select class="a-input sel" name="state">
<option value="">전체</option>
<option value="S" {{ $state==='S'?'selected':'' }}>직접변경(S)</option>
<option value="E" {{ $state==='E'?'selected':'' }}>비번찾기(E)</option>
</select>
</div>
<div>
<label class="muted">mem_no</label>
<input class="a-input inp" name="mem_no" value="{{ $memNo }}" inputmode="numeric" placeholder="5359">
</div>
<div>
<label class="muted">type(신포맷)</label>
<input class="a-input inpWide" name="type" value="{{ $type }}" placeholder="로그인비밀번호변경 / 2차비밀번호변경">
</div>
<div>
<label class="muted">email(구포맷)</label>
<input class="a-input inpWide" name="email" value="{{ $email }}" placeholder="naver.com">
</div>
<div>
<label class="muted">ip</label>
<input class="a-input inp" name="ip" value="{{ $ip }}" placeholder="210.96.">
</div>
<div>
<label class="muted">q(info 검색)</label>
<input class="a-input inpWide" name="q" value="{{ $q }}" placeholder="agent / auth_key ...">
</div>
<div style="display:flex;gap:8px;align-items:flex-end;">
<button class="lbtn lbtn--ghost" type="submit">검색</button>
<a class="lbtn lbtn--ghost" href="{{ $indexUrl }}">초기화</a>
</div>
</form>
</div>
</div>
<div class="a-card" style="padding:16px;">
<div class="a-muted" style="margin-bottom:10px;"> <b>{{ $page->total() }}</b></div>
<div style="overflow:auto;">
<table class="a-table table" style="width:100%; min-width:1300px;">
<thead>
<tr>
<th style="width:90px;">SEQ</th>
<th style="width:190px;">rgdate</th>
<th style="width:130px;">state</th>
<th style="width:140px;">mem_no</th>
<th style="width:200px;">type</th>
<th style="width:220px;">ip</th>
<th style="width:240px;">email</th>
<th>JSON</th>
</tr>
</thead>
<tbody>
@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
<tr>
<td class="a-muted">{{ $seq }}</td>
<td class="a-muted nowrap">{{ $r['rgdate'] ?? '-' }}</td>
<td>
<span class="badge {{ $r['state_badge'] ?? 'badge--warn' }}">
{{ $r['state_label'] ?? ($stateMap[$r['state'] ?? ''] ?? '-') }}
</span>
</td>
<td>
@if($memNoInt > 0 && $memLink)
<a href="{{ $memLink }}" class="mono memlink" target="_blank" rel="noopener">{{ $memNoInt }}</a>
@else
<span class="mono">-</span>
@endif
</td>
<td><span class="mono">{{ $r['event_type'] ?? '-' }}</span></td>
<td class="nowrap">
@if($ipN !== '')
<span class="mono">{{ $ipN }}</span>
@else
<span class="a-muted">-</span>
@endif
</td>
<td>
@if($emailN !== '')
<span class="mono">{{ $emailN }}</span>
@else
<span class="a-muted">-</span>
@endif
</td>
@php
$agent = (string)($r['agent_norm'] ?? '');
$authKey = (string)($r['auth_key'] ?? '');
$authEff = (string)($r['auth_effective_time'] ?? '');
@endphp
<td class="nowrap" style="text-align:right;">
<button type="button"
class="lbtn lbtn--ghost lbtn--sm btnJson"
data-seq="{{ $seq }}"
data-agent="{{ e($agent) }}"
data-auth-key="{{ e($authKey) }}"
data-auth-eff="{{ e($authEff) }}">
JSON 보기
</button>
{{-- AJAX 없이: row별 JSON을 숨겨두고 모달에서 꺼내씀 --}}
<textarea id="jsonStore-{{ $seq }}" style="display:none;">{{ $r['info_pretty'] ?? ($r['info'] ?? '') }}</textarea>
</td>
</tr>
@empty
<tr><td colspan="9" class="a-muted" style="padding:16px;">데이터가 없습니다.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div style="margin-top:12px;">
{{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
</div>
</div>
<div class="mback" id="jsonModalBack" aria-hidden="true">
<div class="modalx" role="dialog" aria-modal="true" aria-labelledby="jsonModalTitle">
<div class="modalx__head">
<div>
<div class="modalx__title" id="jsonModalTitle">JSON</div>
<div class="modalx__desc" id="jsonModalDesc">로그 상세 JSON</div>
</div>
<button class="lbtn lbtn--ghost lbtn--sm" type="button" id="btnJsonClose">닫기 </button>
</div>
<div class="modalx__body">
<div class="kv" id="jsonModalKv"></div>
<div class="prebox">
<pre id="jsonModalPre"></pre>
</div>
</div>
<div class="modalx__foot">
<button class="lbtn lbtn--ghost" type="button" id="btnJsonCopy">복사</button>
<button class="lbtn lbtn--primary" type="button" id="btnJsonOk">확인</button>
</div>
</div>
</div>
<script>
(function(){
const back = document.getElementById('jsonModalBack');
const pre = document.getElementById('jsonModalPre');
const kv = document.getElementById('jsonModalKv');
const btnClose = document.getElementById('btnJsonClose');
const btnOk = document.getElementById('btnJsonOk');
const btnCopy = document.getElementById('btnJsonCopy');
const open = () => {
back.classList.add('is-open');
back.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
};
const close = () => {
back.classList.remove('is-open');
back.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
};
btnClose?.addEventListener('click', close);
btnOk?.addEventListener('click', close);
back?.addEventListener('click', (e)=>{ if(e.target === back) close(); });
document.addEventListener('keydown', (e)=>{ if(e.key === 'Escape' && back.classList.contains('is-open')) close(); });
btnCopy?.addEventListener('click', async () => {
const text = pre.textContent || '';
try {
await navigator.clipboard.writeText(text);
btnCopy.textContent = '복사됨';
setTimeout(()=>btnCopy.textContent='복사', 900);
} catch (e) {
alert('복사를 지원하지 않는 환경입니다.');
}
});
const renderKv = (pairs) => {
kv.innerHTML = '';
pairs.filter(p => p.v && String(p.v).trim() !== '').forEach(p => {
const k = document.createElement('div'); k.className='k'; k.textContent=p.k;
const v = document.createElement('div'); v.className='v';
const span = document.createElement('span'); span.className='mono'; span.textContent=p.v;
v.appendChild(span);
kv.appendChild(k); kv.appendChild(v);
});
};
document.querySelectorAll('.btnJson').forEach(btn => {
btn.addEventListener('click', () => {
const seq = btn.getAttribute('data-seq');
if (!seq) return;
const ta = document.getElementById('jsonStore-' + seq);
const raw = ta ? ta.value : '';
const agent = btn.getAttribute('data-agent') || '';
const authKey = btn.getAttribute('data-auth-key') || '';
const authEff = btn.getAttribute('data-auth-eff') || '';
renderKv([
{k:'agent', v: agent},
{k:'auth_key', v: authKey},
{k:'auth_effective_time', v: authEff},
]);
pre.textContent = raw || '';
open();
});
});
})();
</script>
@endsection

View File

@ -0,0 +1,61 @@
{{-- resources/views/admin/log/_audit_log_modal.blade.php --}}
<div class="mback" id="auditModalBack" aria-hidden="true">
<div class="modalx" role="dialog" aria-modal="true" aria-labelledby="auditTitle">
<div class="modalx__head">
<div>
<div class="modalx__title" id="auditTitle">감사로그 상세</div>
<div class="modalx__desc" id="auditDesc">before/after JSON과 user_agent를 확인하세요.</div>
</div>
<button class="lbtn lbtn--ghost lbtn--sm" type="button" id="auditBtnClose">닫기 </button>
</div>
<div class="modalx__body">
<div class="infoGrid">
<div class="ibox">
<div class="k">ID</div>
<div class="v mono" id="audit_id">-</div>
</div>
<div class="ibox">
<div class="k">Actor</div>
<div class="v" id="audit_actor">-</div>
</div>
<div class="ibox">
<div class="k">Action</div>
<div class="v mono" id="audit_action">-</div>
</div>
<div class="ibox">
<div class="k">Target</div>
<div class="v mono" id="audit_target">-</div>
</div>
<div class="ibox">
<div class="k">IP</div>
<div class="v mono" id="audit_ip">-</div>
</div>
<div class="ibox">
<div class="k">Created</div>
<div class="v mono" id="audit_created">-</div>
</div>
</div>
<div class="ibox" style="margin-bottom:12px;">
<div class="k">User Agent</div>
<div class="v" id="audit_ua" style="font-size:12px; opacity:.9; word-break:break-word;">-</div>
</div>
<div class="jsonGrid">
<div class="prebox">
<div class="k">before_json</div>
<pre id="audit_before">-</pre>
</div>
<div class="prebox">
<div class="k">after_json</div>
<pre id="audit_after">-</pre>
</div>
</div>
</div>
<div class="modalx__foot">
<button class="lbtn lbtn--ghost" type="button" id="auditBtnOk">확인</button>
</div>
</div>
</div>

View File

@ -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')
<style>
.bar{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;}
.filters .inp{width:220px;}
.lbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
.lbtn--ghost{background:transparent;}
.lbtn--sm{padding:7px 10px;font-size:12px;border-radius:11px;}
.lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
.lbtn--primary:hover{background:rgba(59,130,246,.98);}
.pill{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:12px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);}
.pill--ok{border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.10);}
.pill--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
.pill--muted{opacity:.9;}
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;display:inline-block;}
.mono a{color:#fff;text-decoration:none;}
.mono a:hover{text-decoration:underline;}
/* modal */
.mback{position:fixed;inset:0;display:none;align-items:center;justify-content:center;z-index:9999;
background:rgba(0,0,0,.55);backdrop-filter:blur(2px);}
.mback.is-open{display:flex;}
.modalx{width:min(900px, 92vw);max-height:88vh;overflow:auto;border-radius:18px;
border:1px solid rgba(255,255,255,.12);background:rgba(18,18,18,.96);box-shadow:0 20px 60px rgba(0,0,0,.45);}
.modalx__head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;padding:16px 16px 10px;border-bottom:1px solid rgba(255,255,255,.10);}
.modalx__title{font-weight:900;font-size:16px;}
.modalx__body{padding:16px;}
.kv{display:grid;grid-template-columns:140px 1fr;gap:8px 12px;font-size:13px;margin-bottom:14px;}
.kv .k{opacity:.75;}
.pre{white-space:pre;overflow:auto;max-height:52vh;padding:12px;border-radius:14px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.04);
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;line-height:1.55;}
</style>
@endpush
@section('content')
@php
$memNo = (string)($filters['mem_no'] ?? '');
$phone = (string)($filters['phone'] ?? '');
$indexUrl = route('admin.systemlog.member-danalauthtel-logs', [], false);
@endphp
<div class="a-card" style="padding:16px; margin-bottom:16px;">
<div class="bar">
<div>
<div style="font-weight:900;font-size:16px;">다날 휴대폰 본인인증 로그</div>
<div class="a-muted" style="font-size:12px;margin-top:4px;">mem_no / 전화번호만 검색 · 이름/CI/DI 등은 표시하지 않음</div>
</div>
<form method="GET" action="{{ $indexUrl }}" class="filters">
<div>
<label class="a-muted" style="font-size:12px;">mem_no</label>
<input class="a-input inp" name="mem_no" value="{{ $memNo }}" placeholder="예: 70687">
</div>
<div>
<label class="a-muted" style="font-size:12px;">전화번호</label>
<input class="a-input inp" name="phone" value="{{ $phone }}" placeholder="예: 01084492969">
</div>
<div style="display:flex;gap:8px;">
<button class="lbtn lbtn--ghost" type="submit">검색</button>
<a class="lbtn lbtn--ghost" href="{{ $indexUrl }}">초기화</a>
</div>
</form>
</div>
</div>
<div class="a-card" style="padding:16px;">
<div class="a-muted" style="margin-bottom:10px;"> <b>{{ $page->total() }}</b></div>
<div style="overflow:auto;">
<table class="a-table table" style="width:100%; min-width:1100px;">
<thead>
<tr>
<th style="width:90px;">SEQ</th>
<th style="width:90px;">구분</th>
<th style="width:110px;">결과</th>
<th style="width:160px;">회원번호</th>
<th style="width:210px;">전화번호</th>
<th>TID</th>
<th style="width:200px;">인증시간</th>
<th style="width:140px;"></th>
</tr>
</thead>
<tbody>
@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
<tr>
<td class="a-muted">{{ $seq }}</td>
<td><span class="pill">{{ $gLabel }}</span></td>
<td><span class="pill {{ $resCls }}">{{ $rc !== '' ? $rc : '-' }}</span></td>
<td>
@if($mno > 0)
<span class="mono"><a href="{{ $memberUrl }}">{{ $mno }}</a></span>
@else
<span class="pill pill--muted">가입차단/미생성</span>
@endif
</td>
<td>
@if($phoneDisp !== '')
<span class="mono">{{ $phoneDisp }}</span>
@else
<span class="a-muted">-</span>
@endif
</td>
<td><span class="mono">{{ $tid !== '' ? $tid : '-' }}</span></td>
<td class="a-muted">{{ $dt !== '' ? $dt : '-' }}</td>
<td style="text-align:right;">
<button type="button"
class="lbtn lbtn--ghost lbtn--sm btnJson"
data-seq="{{ $seq }}">
JSON 보기
</button>
{{-- row별 JSON(모달용) - 안전하게 script에 저장 --}}
<script type="application/json" id="info-json-{{ $seq }}">
{!! json_encode(($r['info_sanitized'] ?? []), JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_AMP|JSON_HEX_QUOT) !!}
</script>
</td>
</tr>
@empty
<tr><td colspan="8" class="a-muted" style="padding:16px;">데이터가 없습니다.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div style="margin-top:12px;">
{{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
</div>
</div>
{{-- Modal --}}
<div class="mback" id="jsonModalBack" aria-hidden="true">
<div class="modalx" role="dialog" aria-modal="true" aria-labelledby="jsonModalTitle">
<div class="modalx__head">
<div>
<div class="modalx__title" id="jsonModalTitle">JSON 상세</div>
<div class="a-muted" style="font-size:12px;margin-top:4px;">CI/DI/이름/생년월일/성별/이메일은 표시하지 않습니다.</div>
</div>
<button class="lbtn lbtn--ghost lbtn--sm" type="button" id="btnCloseJsonModal">닫기 </button>
</div>
<div class="modalx__body">
<div class="kv" id="jsonKv"></div>
<div class="pre" id="jsonPre">{}</div>
</div>
</div>
</div>
<script>
(function(){
const back = document.getElementById('jsonModalBack');
const btnClose = document.getElementById('btnCloseJsonModal');
const kv = document.getElementById('jsonKv');
const pre = document.getElementById('jsonPre');
const open = () => {
back.classList.add('is-open');
back.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
};
const close = () => {
back.classList.remove('is-open');
back.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
};
const setKV = (obj) => {
const pairs = [
['gubun', obj.gubun || '-'],
['res_code', obj.res_code || '-'],
['mem_no', obj.mem_no || '-'],
['mobile_number', obj.mobile_number || '-'],
['TID', obj.TID || '-'],
['RETURNCODE', obj.RETURNCODE || '-'],
];
kv.innerHTML = pairs.map(([k,v]) => `
<div class="k">${k}</div><div class="v"><span class="mono">${String(v)}</span></div>
`).join('');
};
document.querySelectorAll('.btnJson').forEach(btn => {
btn.addEventListener('click', () => {
const seq = btn.getAttribute('data-seq');
const el = document.getElementById('info-json-' + seq);
if (!el) return;
let obj = {};
try {
obj = JSON.parse(el.textContent || '{}') || {};
} catch(e) {
obj = {};
}
setKV(obj);
pre.textContent = JSON.stringify(obj, null, 2);
open();
});
});
btnClose?.addEventListener('click', close);
back?.addEventListener('click', (e) => { if (e.target === back) close(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && back.classList.contains('is-open')) close(); });
})();
</script>
@endsection

View File

@ -46,14 +46,33 @@
], ],
], ],
[ [
'title' => '상품 관리', 'title' => '상품 관리',
'items' => [ 'items' => [
['label' => '상품 리스트', 'route' => 'admin.products.index','roles' => ['super_admin','product']], // 1) 기본 데이터(전시 구조)
['label' => '상품 등록', 'route' => 'admin.products.create','roles' => ['super_admin','product']], ['label' => '카테고리 관리', 'route' => 'admin.categories.index', 'roles' => ['super_admin','product']], // ✅ 추천(신규)
['label' => '판매 코드 관리', 'route' => 'admin.sale-codes.index','roles' => ['super_admin','product']],
['label' => '핀 번호 관리', 'route' => 'admin.pins.index','roles' => ['super_admin','product']], // 2) 상품 등록/관리
['label' => '메인 노출 관리', 'route' => 'admin.exposure.index','roles' => ['super_admin','product']], ['label' => '상품 리스트', 'route' => 'admin.products.index', 'roles' => ['super_admin','product']],
['label' => '결제 수수료/정책', 'route' => 'admin.fees.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']],
], ],
], ],
[ [
@ -75,11 +94,13 @@
[ [
'title' => '시스템 로그', 'title' => '시스템 로그',
'items' => [ 'items' => [
['label' => '로그인 로그', 'route' => 'admin.logs.login','roles' => ['super_admin','finance','product','support']], ['label' => '다날 결제 로그', 'route' => 'admin.logs.pay','roles' => ['super_admin','finance','product','support']],
['label' => '다날 인증 로그', 'route' => 'admin.logs.danal','roles' => ['super_admin','finance','product','support']], ['label' => '회원 다날 인증 로그', 'route' => 'admin.systemlog.member-danalauthtel-logs','roles' => ['super_admin']],
['label' => '결제 로그', 'route' => 'admin.logs.pay','roles' => ['super_admin','finance','product','support']], ['label' => '회원 계좌번호 성명인증 로그', 'route' => 'admin.systemlog.member-account-logs','roles' => ['super_admin']],
['label' => '기타 로그', 'route' => 'admin.logs.misc','roles' => ['super_admin','finance','product','support']], ['label' => '회원 비밀번호변경 로그', 'route' => 'admin.systemlog.member-passwd-modify-logs','roles' => ['super_admin']],
['label' => '관리자 활동 로그', 'route' => 'admin.logs.audit','roles' => ['super_admin','finance','product','support']], ['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']],
], ],
], ],
]; ];

View File

@ -14,7 +14,12 @@ use App\Http\Controllers\Admin\Qna\AdminQnaController;
use App\Http\Controllers\Admin\Members\AdminMembersController; use App\Http\Controllers\Admin\Members\AdminMembersController;
use App\Http\Controllers\Admin\Members\AdminMemberMarketingController; use App\Http\Controllers\Admin\Members\AdminMemberMarketingController;
use App\Http\Controllers\Admin\Members\AdminMemberJoinFilterController; 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; use Illuminate\Support\Facades\Route;
Route::middleware(['web'])->group(function () { Route::middleware(['web'])->group(function () {
@ -241,6 +246,33 @@ Route::middleware(['web'])->group(function () {
->name('destroy'); ->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');
});
}); });
}); });